Merge branch 'feat/agc-v3-c7-version-switch' into feat/agc-canvas-resource-workbench-v3
This commit is contained in:
@@ -480,6 +480,7 @@ type AppProps = {
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
projectSupervisorOnly?: boolean;
|
||||
planningStartMode?: boolean;
|
||||
activeVersionId?: ProjectSupervisorComponentProps['activeVersionId'];
|
||||
supervisorChatOnly?: boolean;
|
||||
initialSupervisorMessage?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
@@ -515,6 +516,7 @@ export function App({
|
||||
orchestrationMode = 'professional-dag',
|
||||
projectSupervisorOnly = false,
|
||||
planningStartMode = false,
|
||||
activeVersionId = null,
|
||||
supervisorChatOnly = false,
|
||||
initialSupervisorMessage = '',
|
||||
initialCreationType = null,
|
||||
@@ -11092,10 +11094,11 @@ export function App({
|
||||
const chatProjectAssets = manifest.assets.filter(
|
||||
(asset) => asset.localPath && !asset.localPath.startsWith('.agent/'),
|
||||
);
|
||||
// `@` 面板「当前版本素材」的版本来源。本次只透传 manifest 版本列表,
|
||||
// activeVersionId 传 null 表示回退到 manifest 中最新的版本。
|
||||
// `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest,
|
||||
// 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出,
|
||||
// 传 `null` 表示回退到 manifest 中最新的版本。
|
||||
const chatProjectVersions = manifest.versions ?? [];
|
||||
const chatActiveVersionId = null;
|
||||
const chatActiveVersionId = activeVersionId;
|
||||
const visibleMainProjectCheckpoints = projectCheckpoints.slice(0, 5);
|
||||
const currentProjectTitle = localProject
|
||||
? manifest.name.trim() || projectNameFromPath(localProject.projectPath)
|
||||
|
||||
@@ -84,6 +84,11 @@ export function WorkspaceLauncherShell({
|
||||
const activeProjectContextRef = useRef(currentProjectContext);
|
||||
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
|
||||
activeProjectContextRef.current = currentProjectContext;
|
||||
/**
|
||||
* C7 当前游戏版本:唯一一份版本状态,同时喂给资源画布(「当前使用」高亮)与
|
||||
* `@` 面板(「当前版本素材」页签)。`null` 表示回退到 manifest 中最新的版本。
|
||||
*/
|
||||
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextTitle =
|
||||
@@ -112,6 +117,22 @@ export function WorkspaceLauncherShell({
|
||||
currentProjectContext?.projectPath,
|
||||
]);
|
||||
|
||||
// 换项目时回到「最新版本」,不把上一个项目的 versionId 带过去。
|
||||
useEffect(() => {
|
||||
setActiveVersionId(null);
|
||||
}, [currentProjectContext?.projectPath]);
|
||||
|
||||
// 选中的版本已经不在了(例如随素材一并删除)时清回「最新版本」,
|
||||
// 否则版本入口会显示最新版本、而资源卡按空态不高亮,两边口径对不上。
|
||||
const projectVersionIds = (currentProjectContext?.manifest.versions ?? [])
|
||||
.map((version) => version.versionId)
|
||||
.join('\u0000');
|
||||
useEffect(() => {
|
||||
if (!activeVersionId) return;
|
||||
if (projectVersionIds.split('\u0000').includes(activeVersionId)) return;
|
||||
setActiveVersionId(null);
|
||||
}, [activeVersionId, projectVersionIds]);
|
||||
|
||||
const applyManifestSnapshot = useCallback(
|
||||
(snapshot: ProjectManifestSnapshot) => {
|
||||
const current = activeProjectContextRef.current;
|
||||
@@ -329,6 +350,8 @@ export function WorkspaceLauncherShell({
|
||||
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
|
||||
agentResults={activeProjectAgentResults}
|
||||
planningStartMode={currentProjectContext.startMode === 'planning'}
|
||||
activeVersionId={activeVersionId}
|
||||
onActiveVersionChange={setActiveVersionId}
|
||||
onPlay={() =>
|
||||
requestCurrentProjectPlay(currentProjectContext.projectPath)
|
||||
}
|
||||
@@ -344,6 +367,7 @@ export function WorkspaceLauncherShell({
|
||||
initialSupervisorMessage={currentProjectContext.initialPrompt}
|
||||
initialCreationType={currentProjectContext.creationType}
|
||||
initialAttachments={currentProjectContext.attachments}
|
||||
activeVersionId={activeVersionId}
|
||||
orchestrationMode="single-supervisor"
|
||||
projectSupervisorOnly
|
||||
planningStartMode={
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ProjectSupervisorComponentProps = {
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
projectSupervisorOnly?: boolean;
|
||||
planningStartMode?: boolean;
|
||||
/**
|
||||
* C7 当前游戏版本:由工作台壳持有,supervisor 里的 `@` 面板按它切「当前版本素材」。
|
||||
* `null` 表示回退到 manifest 中最新的版本。
|
||||
*/
|
||||
activeVersionId?: string | null;
|
||||
playRequest?: {
|
||||
projectPath: string;
|
||||
requestId: number;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { GameIterationVersion } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { formatIterationVersionLabel } from './resourceCanvasVersionBindingModel';
|
||||
|
||||
type GameRunVersionPickerProps = {
|
||||
versions: readonly GameIterationVersion[];
|
||||
activeVersionId: string | null;
|
||||
onSelectVersion: (versionId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行模块右上角的版本入口。
|
||||
*
|
||||
* 数据只来自 manifest `versions[]`:没有版本时**不渲染**入口,不伪造版本。
|
||||
* 选择只改「哪个版本是当前版本」这条记录层状态;画面刷新由宿主重载既有预览完成。
|
||||
*/
|
||||
export function GameRunVersionPicker({
|
||||
versions,
|
||||
activeVersionId,
|
||||
onSelectVersion,
|
||||
}: GameRunVersionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (rootRef.current?.contains(event.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (versions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentVersion =
|
||||
versions.find((version) => version.versionId === activeVersionId) ??
|
||||
versions[versions.length - 1];
|
||||
if (!currentVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="game-run-version-picker">
|
||||
<button
|
||||
type="button"
|
||||
className="game-run-version-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{formatIterationVersionLabel(currentVersion)}
|
||||
</button>
|
||||
{open
|
||||
? createPortal(
|
||||
<div
|
||||
className="game-run-version-menu"
|
||||
role="listbox"
|
||||
aria-label="切换游戏版本"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
// 用视口坐标而不是相对定位:运行画面会缩放,挂在 body 上才不跟着画布跑偏。
|
||||
top: rootRef.current
|
||||
? `${rootRef.current.getBoundingClientRect().bottom + 6}px`
|
||||
: '0',
|
||||
right: rootRef.current
|
||||
? `${Math.max(
|
||||
8,
|
||||
window.innerWidth -
|
||||
rootRef.current.getBoundingClientRect().right,
|
||||
)}px`
|
||||
: '0',
|
||||
}}
|
||||
>
|
||||
{versions.map((version) => {
|
||||
const selected = version.versionId === currentVersion.versionId;
|
||||
return (
|
||||
<button
|
||||
key={version.versionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`切换到${formatIterationVersionLabel(version)}`}
|
||||
className={selected ? 'is-selected' : undefined}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (selected) return;
|
||||
onSelectVersion(version.versionId);
|
||||
}}
|
||||
>
|
||||
<span>{formatIterationVersionLabel(version)}</span>
|
||||
<small>{version.versionId}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+42
-6
@@ -1,17 +1,53 @@
|
||||
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
GameCreationAppManifest,
|
||||
GameIterationVersion,
|
||||
GameIterationVersionCreatedReason,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
|
||||
import { resolveActiveIterationVersion } from '../project-workspace/resourceReferences';
|
||||
|
||||
/**
|
||||
* C5 卡片边框:一个资源是否正在被「当前版本」使用。
|
||||
* 版本创建原因的中文标签口径。运行模块版本入口与资源详情共用同一份映射,
|
||||
* 不在各调用点各写一遍。
|
||||
*/
|
||||
export const ITERATION_VERSION_REASON_LABELS: Record<
|
||||
GameIterationVersionCreatedReason,
|
||||
string
|
||||
> = {
|
||||
initial: '初始版本',
|
||||
'resource-replacement': '资源替换',
|
||||
'agent-revision': '智能体修订',
|
||||
};
|
||||
|
||||
/** 版本在 UI 里的可读标识:创建原因 + 创建时间。 */
|
||||
export function formatIterationVersionLabel(
|
||||
version: Pick<GameIterationVersion, 'createdReason' | 'createdAt'>,
|
||||
) {
|
||||
const reason =
|
||||
ITERATION_VERSION_REASON_LABELS[version.createdReason] ??
|
||||
version.createdReason;
|
||||
const createdAt = new Date(version.createdAt);
|
||||
const time = Number.isFinite(createdAt.getTime())
|
||||
? createdAt.toLocaleString('zh-CN')
|
||||
: String(version.createdAt);
|
||||
return `${reason} · ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* C5 / C7 卡片边框:一个资源是否正在被「当前版本」使用。
|
||||
*
|
||||
* 当前版本 = manifest `versions[]` 的最后一个版本。版本绑定是资源记录的权威口径,
|
||||
* `slotId` 恒等映射为 `asset:{assetId}`,`resourceId` 指向 manifest 资产 ID。
|
||||
* 版本切换状态接入后,只需把 `manifest` 换成「当前选中的版本」即可复用同一判定。
|
||||
* 版本绑定是资源记录的权威口径,`slotId` 恒等映射为 `asset:{assetId}`,
|
||||
* `resourceId` 指向 manifest 资产 ID。当前版本由 `activeVersionId` 决定,
|
||||
* 解析口径复用 `@` 面板那一条:传 `null` / 不传时回退到 manifest 中最新的版本。
|
||||
*/
|
||||
export function currentVersionResourceBindingIds(
|
||||
manifest: Pick<GameCreationAppManifest, 'versions'>,
|
||||
activeVersionId: string | null = null,
|
||||
): Set<string> {
|
||||
const currentVersion = (manifest.versions ?? []).at(-1);
|
||||
const currentVersion = resolveActiveIterationVersion(
|
||||
manifest.versions,
|
||||
activeVersionId,
|
||||
);
|
||||
if (!currentVersion) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
@@ -7669,6 +7669,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-run-surface {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(300px, 1fr) auto;
|
||||
grid-row: 2 / -1;
|
||||
@@ -7679,6 +7680,82 @@ iframe.preview-frame {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */
|
||||
.game-run-version-picker {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.game-run-version-trigger {
|
||||
display: inline-flex;
|
||||
max-width: min(18rem, 60vw);
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e5cfc4;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 250 246 / 92%);
|
||||
color: #8a4a30;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-run-version-trigger:hover,
|
||||
.game-run-version-trigger:focus-visible {
|
||||
border-color: #cc8060;
|
||||
outline: 0;
|
||||
box-shadow: 0 3px 10px rgb(158 87 57 / 14%);
|
||||
}
|
||||
|
||||
.game-run-version-menu {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: min(20rem, 60vh);
|
||||
min-width: 14rem;
|
||||
padding: 6px;
|
||||
border: 1px solid #e5cfc4;
|
||||
border-radius: 12px;
|
||||
background: #fffaf6;
|
||||
box-shadow: 0 12px 28px rgb(120 70 45 / 18%);
|
||||
overflow-y: auto;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.game-run-version-menu button {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #50382f;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-run-version-menu button small {
|
||||
color: #a08073;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-run-version-menu button:hover,
|
||||
.game-run-version-menu button:focus-visible {
|
||||
background: #fdeee5;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-run-version-menu button.is-selected {
|
||||
background: #f8e2d6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-run-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
|
||||
export type RenameLocalProjectAssetResult = {
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
previousLocalPath: string;
|
||||
committedProjectRevision: number;
|
||||
};
|
||||
|
||||
function assetFileName(localPath: string) {
|
||||
return localPath.split(/[\\/]/u).pop() ?? localPath;
|
||||
}
|
||||
|
||||
type ResourceRenameDialogProps = {
|
||||
open: boolean;
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
renaming: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (newFileName: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 素材重命名面板。
|
||||
*
|
||||
* 只输入新文件名:目录由资产的 `localPath` 决定,用户不能换目录;
|
||||
* 扩展名一致、同名冲突等规则由 Rust 侧 `rename_local_project_asset` 强校验,
|
||||
* 这里只做输入与错误呈现。
|
||||
*/
|
||||
export function ResourceRenameDialog({
|
||||
open,
|
||||
asset,
|
||||
renaming,
|
||||
error,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ResourceRenameDialogProps) {
|
||||
const [draft, setDraft] = useState(() => assetFileName(asset.localPath));
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open={open}
|
||||
ariaLabel="重命名素材"
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-rename-dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>重命名素材</h2>
|
||||
<p>{asset.localPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭重命名素材"
|
||||
onClick={onClose}
|
||||
disabled={renaming}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="game-resource-rename-body">
|
||||
<PlatformTextField
|
||||
aria-label="新文件名"
|
||||
value={draft}
|
||||
disabled={renaming}
|
||||
autoFocus
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !renaming && draft.trim() !== '') {
|
||||
event.preventDefault();
|
||||
onConfirm(draft);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="game-resource-rename-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<footer>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
disabled={renaming}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
aria-label="确认重命名素材"
|
||||
onClick={() => onConfirm(draft)}
|
||||
disabled={renaming || draft.trim() === ''}
|
||||
>
|
||||
重命名
|
||||
</PlatformActionButton>
|
||||
</footer>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Minus,
|
||||
Music2,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import type {
|
||||
GameCreationAppAgentGroup,
|
||||
GameCreationAppAssetManifestEntry,
|
||||
@@ -64,6 +65,10 @@ import type {
|
||||
GameIterationVersion,
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
assetTagsMatchSelection,
|
||||
buildGameCreationAppAssetTagLibrary,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
QuickEditPanelState,
|
||||
@@ -80,6 +85,7 @@ import {
|
||||
RESOURCE_REFERENCE_FILTERS,
|
||||
type ResourceReferenceFilter,
|
||||
} from '../../features/project-workspace/resourceReferences';
|
||||
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
|
||||
import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel';
|
||||
import {
|
||||
canNormalizeResourceIntoManifestAsset,
|
||||
@@ -179,6 +185,10 @@ import {
|
||||
projectResourcesFromReadModels,
|
||||
projectResourceTypeLabel,
|
||||
} from './resourceProjectionModel';
|
||||
import {
|
||||
type RenameLocalProjectAssetResult,
|
||||
ResourceRenameDialog,
|
||||
} from './ResourceRenameDialog';
|
||||
import {
|
||||
clampProjectResourceSectionZoom,
|
||||
projectResourceSectionZoomFromWheel,
|
||||
@@ -398,6 +408,12 @@ export type ProjectDevelopmentViewProps = {
|
||||
planningStartMode?: boolean;
|
||||
supervisor: ReactNode;
|
||||
walletEntry?: ReactNode;
|
||||
/**
|
||||
* 当前游戏版本 id。`null` 表示回退到 manifest 中最新的版本。
|
||||
* 与 `@` 面板共用同一份状态,资源画布的「当前使用」高亮也从这里取版本。
|
||||
*/
|
||||
activeVersionId?: string | null;
|
||||
onActiveVersionChange?: (versionId: string) => void;
|
||||
onHomeOpen: () => void;
|
||||
onProjectsOpen: () => void;
|
||||
onPlay?: () => void;
|
||||
@@ -1124,6 +1140,8 @@ export default function ProjectDevelopmentView({
|
||||
planningStartMode = false,
|
||||
supervisor,
|
||||
walletEntry,
|
||||
activeVersionId = null,
|
||||
onActiveVersionChange,
|
||||
onManifestChange,
|
||||
onPlay,
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
@@ -1172,6 +1190,13 @@ export default function ProjectDevelopmentView({
|
||||
useState<ResourceCanvasCategoryFilterBySortMode>(
|
||||
defaultResourceCanvasCategoryFilters,
|
||||
);
|
||||
/**
|
||||
* 画布标签筛选:与「按依赖 / 按类型」无关,是画布级的收窄条件;
|
||||
* 与 `@` 面板、参考图弹窗的筛选状态互相独立。
|
||||
*/
|
||||
const [resourceCanvasActiveTags, setResourceCanvasActiveTags] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [resourceCanvasViewports, setResourceCanvasViewports] =
|
||||
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
|
||||
const resourceCanvasViewportTargetsRef = useRef(
|
||||
@@ -1214,6 +1239,14 @@ export default function ProjectDevelopmentView({
|
||||
useState(false);
|
||||
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
|
||||
useState<string | null>(null);
|
||||
/** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */
|
||||
const [resourceRenameAssetId, setResourceRenameAssetId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [resourceRenaming, setResourceRenaming] = useState(false);
|
||||
const [resourceRenameError, setResourceRenameError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingResourceEditActionIds, setPendingResourceEditActionIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
|
||||
@@ -1598,9 +1631,13 @@ export default function ProjectDevelopmentView({
|
||||
const selectedResource =
|
||||
canvasResources.find((resource) => resource.id === selectedResourceId) ??
|
||||
null;
|
||||
const projectVersions = useMemo(
|
||||
() => manifest.versions ?? [],
|
||||
[manifest.versions],
|
||||
);
|
||||
const currentVersionBindingIds = useMemo(
|
||||
() => currentVersionResourceBindingIds(manifest),
|
||||
[manifest],
|
||||
() => currentVersionResourceBindingIds(manifest, activeVersionId),
|
||||
[activeVersionId, manifest],
|
||||
);
|
||||
// 做方案在拿到第一个产物之前,左侧的资源区域是空的(文档 0 项、项目版本 0 项),而澄清
|
||||
// 问答、决定卡和 GDD 审批全挤在右边那一列里。这段时间先把画布收掉让对话铺满,一旦有第
|
||||
@@ -1741,6 +1778,11 @@ export default function ProjectDevelopmentView({
|
||||
}, [resources, selectedResourceId]);
|
||||
const normalizedSearch = searchText.trim().toLowerCase();
|
||||
const resourceCategoryFilter = resourceCanvasCategoryFilters[sortMode];
|
||||
/** 标签库是从 manifest `assets[].tags` 派生的数据,manifest 一改就重算。 */
|
||||
const resourceTagLibrary = useMemo(
|
||||
() => buildGameCreationAppAssetTagLibrary(manifest.assets),
|
||||
[manifest.assets],
|
||||
);
|
||||
const visibleResources = useMemo(
|
||||
() =>
|
||||
canvasResources.filter((resource) => {
|
||||
@@ -1750,6 +1792,15 @@ export default function ProjectDevelopmentView({
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// 标签筛选与功能分类筛选共存且可叠加:分类过了还得命中全部已选标签。
|
||||
if (
|
||||
!assetTagsMatchSelection(
|
||||
resource.assetTags ?? [],
|
||||
resourceCanvasActiveTags,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return normalizedSearch
|
||||
? [
|
||||
resource.label,
|
||||
@@ -1759,7 +1810,12 @@ export default function ProjectDevelopmentView({
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch))
|
||||
: true;
|
||||
}),
|
||||
[canvasResources, normalizedSearch, resourceCategoryFilter],
|
||||
[
|
||||
canvasResources,
|
||||
normalizedSearch,
|
||||
resourceCanvasActiveTags,
|
||||
resourceCategoryFilter,
|
||||
],
|
||||
);
|
||||
const visibleResourceIds = useMemo(
|
||||
() => new Set(visibleResources.map((resource) => resource.id)),
|
||||
@@ -2175,6 +2231,15 @@ export default function ProjectDevelopmentView({
|
||||
: null,
|
||||
[manifest.assets, resourceClassificationAssetId],
|
||||
);
|
||||
const resourceRenameAsset = useMemo(
|
||||
() =>
|
||||
resourceRenameAssetId
|
||||
? (manifest.assets.find(
|
||||
(asset) => asset.id === resourceRenameAssetId,
|
||||
) ?? null)
|
||||
: null,
|
||||
[manifest.assets, resourceRenameAssetId],
|
||||
);
|
||||
/**
|
||||
* 资源分类更新与素材删除都只改 manifest,成功后重读一次并按新 revision 投影;
|
||||
* 两者共用同一条重载路径,避免出现两份略有差异的刷新逻辑。
|
||||
@@ -2182,6 +2247,7 @@ export default function ProjectDevelopmentView({
|
||||
const reloadManifestAfterAssetCommand = useCallback(
|
||||
async (committedProjectRevision: number, commitId: string) => {
|
||||
setResourceClassificationAssetId(null);
|
||||
setResourceRenameAssetId(null);
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke || !onManifestChange) return;
|
||||
try {
|
||||
@@ -2225,6 +2291,48 @@ export default function ProjectDevelopmentView({
|
||||
},
|
||||
[reloadManifestAfterAssetCommand],
|
||||
);
|
||||
/**
|
||||
* 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。
|
||||
* Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。
|
||||
*/
|
||||
const confirmResourceRename = useCallback(
|
||||
async (newFileName: string) => {
|
||||
const asset = resourceRenameAsset;
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!asset) return;
|
||||
if (!invoke) {
|
||||
setResourceRenameError('重命名素材需要在客户端内执行');
|
||||
return;
|
||||
}
|
||||
setResourceRenaming(true);
|
||||
setResourceRenameError(null);
|
||||
try {
|
||||
const result = await invoke<RenameLocalProjectAssetResult>(
|
||||
'rename_local_project_asset',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
assetId: asset.id,
|
||||
newFileName,
|
||||
},
|
||||
},
|
||||
);
|
||||
await reloadManifestAfterAssetCommand(
|
||||
result.committedProjectRevision,
|
||||
`asset-rename:${asset.id}`,
|
||||
);
|
||||
} catch (renameError) {
|
||||
const message =
|
||||
renameError instanceof Error
|
||||
? renameError.message
|
||||
: String(renameError);
|
||||
setResourceRenameError(message.trim() || '重命名素材失败');
|
||||
} finally {
|
||||
setResourceRenaming(false);
|
||||
}
|
||||
},
|
||||
[projectPath, reloadManifestAfterAssetCommand, resourceRenameAsset],
|
||||
);
|
||||
const hasRegisteredArtImageAssets = manifest.assets.some(
|
||||
(asset) =>
|
||||
asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'),
|
||||
@@ -4302,6 +4410,22 @@ export default function ProjectDevelopmentView({
|
||||
visibleResourceIds,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 切换当前版本只改「哪个版本是当前版本」这条记录层状态,并让运行模块重新加载当前预览。
|
||||
*
|
||||
* 画面本身不需要运行时按版本重映射:素材不可变(编辑产出新素材而不是改文件),
|
||||
* 所以版本之间没变的资源本来就是同一份文件,重载预览即可回到该版本对应的画面。
|
||||
*/
|
||||
function selectActiveVersion(versionId: string) {
|
||||
if (versionId === activeVersionId) {
|
||||
return;
|
||||
}
|
||||
onActiveVersionChange?.(versionId);
|
||||
if (mode === 'run' && runAvailable) {
|
||||
onPlay?.();
|
||||
}
|
||||
}
|
||||
|
||||
function showRunView() {
|
||||
if (!runAvailable || uiEditorRoute) {
|
||||
return;
|
||||
@@ -4968,6 +5092,22 @@ export default function ProjectDevelopmentView({
|
||||
<span>分类与标签</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="重命名"
|
||||
title="重命名"
|
||||
icon={<Pencil className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
setResourceRenameError(null);
|
||||
setResourceRenameAssetId(
|
||||
selectedResource.manifestAssetId,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>重命名</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
onOpenQuickEditPanel={openResourceQuickEditPanel}
|
||||
@@ -5064,16 +5204,21 @@ export default function ProjectDevelopmentView({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_REFERENCE_FILTERS}
|
||||
activeId={resourceCategoryFilter}
|
||||
onChange={showResourceCategoryFilter}
|
||||
layout="scroll"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
className="platform-theme platform-theme--light game-resource-category-filter"
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="资源筛选"
|
||||
categoryItems={RESOURCE_REFERENCE_FILTERS}
|
||||
activeCategoryId={resourceCategoryFilter}
|
||||
onCategoryChange={showResourceCategoryFilter}
|
||||
tagItems={resourceTagLibrary}
|
||||
activeTags={resourceCanvasActiveTags}
|
||||
onToggleTag={(tag) => {
|
||||
setResourceCanvasActiveTags((current) =>
|
||||
current.includes(tag)
|
||||
? current.filter((item) => item !== tag)
|
||||
: [...current, tag],
|
||||
);
|
||||
}}
|
||||
className="game-resource-category-filter"
|
||||
/>
|
||||
{resourceWorkbenchNotice ? (
|
||||
<div className="game-resource-live-notice" role="status">
|
||||
@@ -5371,6 +5516,11 @@ export default function ProjectDevelopmentView({
|
||||
</>
|
||||
) : (
|
||||
<section className="game-run-surface" aria-label="运行表现层">
|
||||
<GameRunVersionPicker
|
||||
versions={projectVersions}
|
||||
activeVersionId={activeVersionId}
|
||||
onSelectVersion={selectActiveVersion}
|
||||
/>
|
||||
<div className="game-run-preview">
|
||||
{embeddedPreviewUrl ? (
|
||||
<LocalGamePreviewFrame
|
||||
@@ -5516,6 +5666,20 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{resourceRenameAsset ? (
|
||||
<ResourceRenameDialog
|
||||
key={resourceRenameAsset.id}
|
||||
open
|
||||
asset={resourceRenameAsset}
|
||||
renaming={resourceRenaming}
|
||||
error={resourceRenameError}
|
||||
onClose={() => {
|
||||
setResourceRenameError(null);
|
||||
setResourceRenameAssetId(null);
|
||||
}}
|
||||
onConfirm={(newFileName) => void confirmResourceRename(newFileName)}
|
||||
/>
|
||||
) : null}
|
||||
{resourceRecoveryPanelOpen ? (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
currentVersionResourceBindingIds,
|
||||
formatIterationVersionLabel,
|
||||
isResourceUsedByCurrentVersion,
|
||||
} from '../src/features/resource-canvas/resourceCanvasVersionBindingModel';
|
||||
|
||||
const versions = [
|
||||
{
|
||||
versionId: 'version-root',
|
||||
parentVersionId: null,
|
||||
projectRevision: 3,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
|
||||
],
|
||||
createdReason: 'initial' as const,
|
||||
createdAt: 1_760_000_000_000,
|
||||
},
|
||||
{
|
||||
versionId: 'version-child',
|
||||
parentVersionId: 'version-root',
|
||||
projectRevision: 4,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-town', resourceId: 'asset-town' },
|
||||
// 悬空绑定的 slotId 不是恒等映射,不参与「当前使用」判定。
|
||||
{ slotId: 'player', resourceId: 'asset-player' },
|
||||
],
|
||||
createdReason: 'agent-revision' as const,
|
||||
createdAt: 1_760_003_600_000,
|
||||
},
|
||||
];
|
||||
|
||||
describe('运行模块版本绑定判定', () => {
|
||||
test('falls back to the newest manifest version when no version is selected', () => {
|
||||
expect(Array.from(currentVersionResourceBindingIds({ versions }))).toEqual([
|
||||
'asset-town',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves the binding ids of the explicitly selected version', () => {
|
||||
expect(
|
||||
Array.from(
|
||||
currentVersionResourceBindingIds({ versions }, 'version-root'),
|
||||
),
|
||||
).toEqual(['asset-player']);
|
||||
});
|
||||
|
||||
test('returns no bindings for a missing version or a manifest without versions', () => {
|
||||
expect(
|
||||
currentVersionResourceBindingIds({ versions }, 'version-missing').size,
|
||||
).toBe(0);
|
||||
expect(currentVersionResourceBindingIds({}).size).toBe(0);
|
||||
expect(currentVersionResourceBindingIds({ versions: [] }).size).toBe(0);
|
||||
});
|
||||
|
||||
test('marks only registered assets bound by the current version as in use', () => {
|
||||
const bindingIds = currentVersionResourceBindingIds(
|
||||
{ versions },
|
||||
'version-root',
|
||||
);
|
||||
|
||||
expect(
|
||||
isResourceUsedByCurrentVersion(
|
||||
{ manifestAssetId: 'asset-player' },
|
||||
bindingIds,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isResourceUsedByCurrentVersion(
|
||||
{ manifestAssetId: 'asset-town' },
|
||||
bindingIds,
|
||||
),
|
||||
).toBe(false);
|
||||
// 未登记为 manifest 素材的资源永远不算「当前使用」。
|
||||
expect(
|
||||
isResourceUsedByCurrentVersion({ manifestAssetId: null }, bindingIds),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('labels a version with its Chinese created reason and creation time', () => {
|
||||
expect(formatIterationVersionLabel(versions[0])).toBe(
|
||||
`初始版本 · ${new Date(1_760_000_000_000).toLocaleString('zh-CN')}`,
|
||||
);
|
||||
expect(formatIterationVersionLabel(versions[1])).toBe(
|
||||
`智能体修订 · ${new Date(1_760_003_600_000).toLocaleString('zh-CN')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
cleanup,
|
||||
createGameCreationAppManifest,
|
||||
fireEvent,
|
||||
ProjectDevelopmentView,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const PROJECT_PATH = '/tmp/workbench-asset-rename';
|
||||
|
||||
/** 资源卡懒加载预览要靠 IntersectionObserver 才认成可见,这里只做最小替身。 */
|
||||
function installResourceCardIntersectionObserver() {
|
||||
class ResourceCardIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '160px';
|
||||
readonly thresholds = [0];
|
||||
readonly observed = new Set<Element>();
|
||||
|
||||
constructor(readonly callback: IntersectionObserverCallback) {}
|
||||
|
||||
observe(element: Element) {
|
||||
this.observed.add(element);
|
||||
}
|
||||
|
||||
unobserve(element: Element) {
|
||||
this.observed.delete(element);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observed.clear();
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: ResourceCardIntersectionObserver,
|
||||
});
|
||||
}
|
||||
|
||||
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 createManifestWithHero(localPath: string): GameCreationAppManifest {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-asset-rename',
|
||||
'素材重命名测试',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'asset-hero',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath,
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function openHeroCard(name = 'hero.png') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
const outline = await screen.findByLabelText('资源栏目大纲');
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
|
||||
// 资源画本先切到子画布再渲染卡片,等它落到 art 栏目再找卡。
|
||||
await waitFor(() => {
|
||||
const manager = document.querySelector('[data-resource-book-view="child"]');
|
||||
expect(manager).not.toBeNull();
|
||||
expect(
|
||||
manager?.querySelector(
|
||||
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="art"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
// C3 后点卡是「选中 + 浮出工具条」,重命名入口在工具条上。
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: new RegExp(`^选中资源:美术资源 ${name}$`),
|
||||
}),
|
||||
);
|
||||
return screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
}
|
||||
|
||||
function renderWorkbench(
|
||||
manifest: GameCreationAppManifest,
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
next: GameCreationAppManifest,
|
||||
metadata: unknown,
|
||||
) => void,
|
||||
) {
|
||||
const { rerender } = render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onManifestChange,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
rerenderWith(next: GameCreationAppManifest) {
|
||||
rerender(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: next.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest: next,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onManifestChange,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
describe('素材重命名前端链路', () => {
|
||||
test('renames through the strict native command and refreshes the resource card name', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const renamedManifest = createManifestWithHero('assets/hero-v2.png');
|
||||
const invoke = installInvoke(async (command) => {
|
||||
if (command === 'rename_local_project_asset') {
|
||||
return {
|
||||
asset: renamedManifest.assets[0],
|
||||
previousLocalPath: 'assets/hero.png',
|
||||
committedProjectRevision: 12,
|
||||
};
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return renamedManifest;
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
const onManifestChange = vi.fn();
|
||||
|
||||
const rendered = renderWorkbench(
|
||||
createManifestWithHero('assets/hero.png'),
|
||||
onManifestChange,
|
||||
);
|
||||
const toolbar = await openHeroCard();
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
|
||||
const field = (await screen.findByLabelText(
|
||||
'新文件名',
|
||||
)) as HTMLInputElement;
|
||||
expect(field.value).toBe('hero.png');
|
||||
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onManifestChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// 入参是 deny_unknown_fields 结构体:只允许这三个字段,多传会被 Rust 直接拒绝。
|
||||
expect(invoke).toHaveBeenCalledWith('rename_local_project_asset', {
|
||||
input: {
|
||||
projectPath: PROJECT_PATH,
|
||||
assetId: 'asset-hero',
|
||||
newFileName: 'hero-v2.png',
|
||||
},
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
|
||||
projectPath: PROJECT_PATH,
|
||||
commandId: 'asset.list',
|
||||
});
|
||||
expect(onManifestChange.mock.calls[0]?.[2]).toMatchObject({
|
||||
revision: 12,
|
||||
source: 'asset-command',
|
||||
commitId: 'asset-rename:asset-hero',
|
||||
});
|
||||
|
||||
rendered.rerenderWith(renamedManifest);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '选中资源:美术资源 hero-v2.png',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the panel open and surfaces the native rejection', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
installInvoke(async (command) => {
|
||||
if (command === 'rename_local_project_asset') {
|
||||
throw '新文件名非法:扩展名必须与原文件一致';
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
renderWorkbench(createManifestWithHero('assets/hero.png'));
|
||||
const toolbar = await openHeroCard();
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
|
||||
const field = (await screen.findByLabelText(
|
||||
'新文件名',
|
||||
)) as HTMLInputElement;
|
||||
fireEvent.change(field, { target: { value: 'hero.jpg' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('扩展名必须与原文件一致');
|
||||
expect(screen.getByLabelText('新文件名')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
cleanup,
|
||||
createGameCreationAppManifest,
|
||||
createGameCreationAppSeedTasks,
|
||||
fireEvent,
|
||||
ProjectDevelopmentView,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const PROJECT_PATH = '/tmp/workbench-version-switch';
|
||||
|
||||
function installResourceCardIntersectionObserver() {
|
||||
class ResourceCardIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '160px';
|
||||
readonly thresholds = [0];
|
||||
readonly observed = new Set<Element>();
|
||||
|
||||
constructor(readonly callback: IntersectionObserverCallback) {}
|
||||
|
||||
observe(element: Element) {
|
||||
this.observed.add(element);
|
||||
}
|
||||
|
||||
unobserve(element: Element) {
|
||||
this.observed.delete(element);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observed.clear();
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: ResourceCardIntersectionObserver,
|
||||
});
|
||||
}
|
||||
|
||||
function createVersionedManifest(): GameCreationAppManifest {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-version-switch',
|
||||
'版本切换测试',
|
||||
);
|
||||
// 运行模块的可用性与版本入口无关,这里用一条已完成原型任务把它打开。
|
||||
manifest.tasks = createGameCreationAppSeedTasks().map((task) =>
|
||||
task.id === 'code-prototype'
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'asset-player',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/player.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'asset-town',
|
||||
kind: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/town.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-root',
|
||||
parentVersionId: null,
|
||||
projectRevision: 3,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
|
||||
],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1_760_000_000_000,
|
||||
},
|
||||
{
|
||||
versionId: 'version-child',
|
||||
parentVersionId: 'version-root',
|
||||
projectRevision: 4,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-town', resourceId: 'asset-town' },
|
||||
],
|
||||
createdReason: 'agent-revision',
|
||||
createdAt: 1_760_003_600_000,
|
||||
},
|
||||
];
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function renderWorkbench(
|
||||
manifest: GameCreationAppManifest,
|
||||
props: {
|
||||
activeVersionId?: string | null;
|
||||
onActiveVersionChange?: (versionId: string) => void;
|
||||
} = {},
|
||||
) {
|
||||
const { rerender } = render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onPlay: vi.fn(),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
rerenderWith(next: Partial<Record<string, unknown>>) {
|
||||
rerender(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onPlay: vi.fn(),
|
||||
...props,
|
||||
...next,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openArtCategory() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
const outline = await screen.findByLabelText('资源栏目大纲');
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-resource-book-view="child"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
function cardFor(label: string) {
|
||||
return screen
|
||||
.getByRole('button', { name: `选中资源:美术资源 ${label}` })
|
||||
.closest('.game-resource-card');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('C7 运行模块版本切换', () => {
|
||||
test('keeps the version entry hidden without versions and shows the latest one by default', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const manifest = createVersionedManifest();
|
||||
manifest.versions = [];
|
||||
const withoutVersions = renderWorkbench(manifest);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
expect(screen.queryByLabelText(/^当前版本:/)).toBeNull();
|
||||
|
||||
withoutVersions.rerenderWith({ manifest: createVersionedManifest() });
|
||||
const trigger = await screen.findByLabelText(/^当前版本:/);
|
||||
expect(trigger.getAttribute('aria-label')).toContain('智能体修订');
|
||||
});
|
||||
|
||||
test('switches the current version through the entry and reports it to the host', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const manifest = createVersionedManifest();
|
||||
const onActiveVersionChange = vi.fn();
|
||||
renderWorkbench(manifest, { onActiveVersionChange });
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
|
||||
|
||||
const menu = await screen.findByRole('listbox', {
|
||||
name: '切换游戏版本',
|
||||
});
|
||||
expect(within(menu).getAllByRole('option')).toHaveLength(2);
|
||||
// 当前版本(最新的那个)已在菜单里标记为选中。
|
||||
expect(
|
||||
within(menu)
|
||||
.getByRole('option', { name: /智能体修订/ })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(within(menu).getByRole('option', { name: /初始版本/ }));
|
||||
expect(onActiveVersionChange).toHaveBeenCalledWith('version-root');
|
||||
});
|
||||
|
||||
test('drives the "current use" card highlight from the active version', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const manifest = createVersionedManifest();
|
||||
const rendered = renderWorkbench(manifest);
|
||||
await openArtCategory();
|
||||
|
||||
// 默认当前版本是 manifest 里最新的那个:只有它绑定的资源算「当前使用」。
|
||||
expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
cardFor('player.png')?.classList.contains('is-current-version'),
|
||||
).toBe(false);
|
||||
|
||||
rendered.rerenderWith({ activeVersionId: 'version-root' });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
cardFor('player.png')?.classList.contains('is-current-version'),
|
||||
).toBe(true);
|
||||
});
|
||||
expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// 选中的版本已经不存在时按空态处理(与 `@` 面板同一口径),不残留旧高亮;
|
||||
// 工作台壳会在版本消失时把选择清回「最新版本」。
|
||||
rendered.rerenderWith({ activeVersionId: 'version-removed' });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
cardFor('town.png')?.classList.contains('is-current-version'),
|
||||
).toBe(false);
|
||||
});
|
||||
expect(
|
||||
cardFor('player.png')?.classList.contains('is-current-version'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformResourceFilterBar } from './PlatformResourceFilterBar';
|
||||
|
||||
const CATEGORY_ITEMS = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'character', label: '角色与对象' },
|
||||
];
|
||||
|
||||
describe('PlatformResourceFilterBar', () => {
|
||||
test('renders a controlled search box, category tabs and tag chips', () => {
|
||||
render(
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="素材筛选"
|
||||
search={{
|
||||
value: '像素',
|
||||
label: '搜索素材',
|
||||
placeholder: '搜索名称',
|
||||
onChange: () => {},
|
||||
}}
|
||||
categoryItems={CATEGORY_ITEMS}
|
||||
activeCategoryId="character"
|
||||
onCategoryChange={() => {}}
|
||||
tagItems={[
|
||||
{ tag: '像素风', assetCount: 2 },
|
||||
{ tag: '主角', assetCount: 1 },
|
||||
]}
|
||||
activeTags={['主角']}
|
||||
onToggleTag={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const search = screen.getByLabelText('搜索素材') as HTMLInputElement;
|
||||
expect(search.value).toBe('像素');
|
||||
expect(search.getAttribute('placeholder')).toBe('搜索名称');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '角色与对象' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: /主角/ }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: /像素风/ })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
test('reports search, category and tag changes without owning state', () => {
|
||||
const onChange = vi.fn();
|
||||
const onCategoryChange = vi.fn();
|
||||
const onToggleTag = vi.fn();
|
||||
render(
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="素材筛选"
|
||||
search={{ value: '', label: '搜索素材', onChange }}
|
||||
categoryItems={CATEGORY_ITEMS}
|
||||
activeCategoryId="all"
|
||||
onCategoryChange={onCategoryChange}
|
||||
tagItems={[{ tag: '像素风', assetCount: 2 }]}
|
||||
activeTags={[]}
|
||||
onToggleTag={onToggleTag}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('搜索素材'), {
|
||||
target: { value: '主角' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /像素风/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('主角');
|
||||
expect(onCategoryChange).toHaveBeenCalledWith('character');
|
||||
expect(onToggleTag).toHaveBeenCalledWith('像素风');
|
||||
});
|
||||
|
||||
test('hides the search row and the tag row when the host omits them', () => {
|
||||
render(
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="素材筛选"
|
||||
categoryItems={CATEGORY_ITEMS}
|
||||
activeCategoryId="all"
|
||||
onCategoryChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('searchbox')).toBeNull();
|
||||
expect(screen.queryByRole('group', { name: '素材筛选标签' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '全部' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Search, Tag } from 'lucide-react';
|
||||
import type { Ref } from 'react';
|
||||
|
||||
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
||||
|
||||
export type PlatformResourceFilterOption<TId extends string = string> = {
|
||||
id: TId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type PlatformResourceTagOption = {
|
||||
tag: string;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export type PlatformResourceFilterSearch = {
|
||||
value: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export type PlatformResourceFilterBarProps<TId extends string = string> = {
|
||||
/** 控件组无障碍名称,例如「资源筛选」。 */
|
||||
ariaLabel: string;
|
||||
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
|
||||
search?: PlatformResourceFilterSearch;
|
||||
/** 功能分类选项,通常来自唯一的分类口径常量。 */
|
||||
categoryItems: readonly PlatformResourceFilterOption<TId>[];
|
||||
activeCategoryId: TId;
|
||||
onCategoryChange: (id: TId) => void;
|
||||
/** 派生标签库;为空时不渲染标签行。 */
|
||||
tagItems?: readonly PlatformResourceTagOption[];
|
||||
/** 已选标签(多选叠加)。 */
|
||||
activeTags?: readonly string[];
|
||||
onToggleTag?: (tag: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function tagChipClassName(active: boolean) {
|
||||
return [
|
||||
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
|
||||
active ? 'platform-category-chip--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源搜索 + 功能分类 + 标签的筛选条。
|
||||
*
|
||||
* 只承载表现与交互:筛选条件(搜索词、分类、已选标签)与筛后结果都由宿主持有,
|
||||
* 因此同一个组件可以在资源画布、参考图弹窗等位置各自持有一份互不影响的筛选状态。
|
||||
*/
|
||||
export function PlatformResourceFilterBar<TId extends string = string>({
|
||||
ariaLabel,
|
||||
search,
|
||||
categoryItems,
|
||||
activeCategoryId,
|
||||
onCategoryChange,
|
||||
tagItems,
|
||||
activeTags = [],
|
||||
onToggleTag,
|
||||
className,
|
||||
}: PlatformResourceFilterBarProps<TId>) {
|
||||
const tagOptions = tagItems ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={['flex min-w-0 flex-wrap items-center gap-2', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{search ? (
|
||||
<label className="flex min-h-10 min-w-[9rem] flex-1 items-center gap-2 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/70 px-3">
|
||||
<Search
|
||||
className="h-4 w-4 shrink-0 text-[var(--platform-text-muted)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={search.inputRef}
|
||||
type="search"
|
||||
aria-label={search.label}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
value={search.value}
|
||||
placeholder={search.placeholder}
|
||||
onChange={(event) => search.onChange(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<PlatformSegmentedTabs
|
||||
items={categoryItems}
|
||||
activeId={activeCategoryId}
|
||||
onChange={onCategoryChange}
|
||||
layout="scroll"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
className="platform-theme platform-theme--light min-w-0 flex-none"
|
||||
/>
|
||||
{tagOptions.length > 0 ? (
|
||||
<div
|
||||
className="platform-category-chip-scroll min-w-0 flex-1"
|
||||
role="group"
|
||||
aria-label={`${ariaLabel}标签`}
|
||||
>
|
||||
{tagOptions.map((option) => {
|
||||
const active = activeTags.includes(option.tag);
|
||||
return (
|
||||
<button
|
||||
key={option.tag}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={tagChipClassName(active)}
|
||||
onClick={() => onToggleTag?.(option.tag)}
|
||||
>
|
||||
<Tag className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{option.tag}</span>
|
||||
<span aria-hidden="true">{option.assetCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assetTagsMatchSelection,
|
||||
buildGameCreationAppAssetTagLibrary,
|
||||
gameCreationAppAssetMatchesTags,
|
||||
} from './gameCreationAppAssetTagLibrary';
|
||||
|
||||
describe('AI 游戏创作 App 标签库派生', () => {
|
||||
it('dedupes tags and counts how many assets use each tag', () => {
|
||||
const library = buildGameCreationAppAssetTagLibrary([
|
||||
{ id: 'asset-a', tags: ['像素风', '主角'] },
|
||||
{ id: 'asset-b', tags: ['像素风'] },
|
||||
{ id: 'asset-c', tags: [] },
|
||||
{ id: 'asset-d', tags: undefined },
|
||||
]);
|
||||
|
||||
expect(library).toEqual([
|
||||
{ tag: '像素风', assetCount: 2, assetIds: ['asset-a', 'asset-b'] },
|
||||
{ tag: '主角', assetCount: 1, assetIds: ['asset-a'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the order stable across input permutations', () => {
|
||||
const first = buildGameCreationAppAssetTagLibrary([
|
||||
{ id: 'asset-a', tags: ['b', 'a'] },
|
||||
{ id: 'asset-b', tags: ['b', 'c'] },
|
||||
]);
|
||||
const second = buildGameCreationAppAssetTagLibrary([
|
||||
{ id: 'asset-b', tags: ['c', 'b'] },
|
||||
{ id: 'asset-a', tags: ['a', 'b'] },
|
||||
]);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.map((entry) => entry.tag)).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('counts a repeated tag on one asset once and ignores blank tags', () => {
|
||||
const library = buildGameCreationAppAssetTagLibrary([
|
||||
{ id: 'asset-a', tags: ['像素风', '像素风', ' ', ''] },
|
||||
]);
|
||||
|
||||
expect(library).toEqual([
|
||||
{ tag: '像素风', assetCount: 1, assetIds: ['asset-a'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty library without registered assets', () => {
|
||||
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires every selected tag to match and treats an empty selection as no filter', () => {
|
||||
const tags = ['像素风', '主角'];
|
||||
|
||||
expect(assetTagsMatchSelection(tags, [])).toBe(true);
|
||||
expect(assetTagsMatchSelection(tags, ['像素风'])).toBe(true);
|
||||
expect(assetTagsMatchSelection(tags, ['像素风', '主角'])).toBe(true);
|
||||
expect(assetTagsMatchSelection(tags, ['像素风', '场景'])).toBe(false);
|
||||
expect(assetTagsMatchSelection(undefined, ['像素风'])).toBe(false);
|
||||
expect(gameCreationAppAssetMatchesTags({ tags }, ['主角'])).toBe(true);
|
||||
expect(gameCreationAppAssetMatchesTags({ tags: undefined }, ['主角'])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
type GameCreationAppAssetManifestEntry,
|
||||
gameCreationAppAssetTags,
|
||||
normalizeGameCreationAppAssetTags,
|
||||
} from './gameCreationApp';
|
||||
|
||||
/**
|
||||
* 标签库里的一项。标签库是从 manifest `assets[].tags` 派生出来的数据,
|
||||
* 不新增任何持久化字段:manifest 一改,标签库重算即可。
|
||||
*/
|
||||
export interface GameCreationAppAssetTagLibraryEntry {
|
||||
tag: string;
|
||||
/** 使用该标签的已登记素材数量。 */
|
||||
assetCount: number;
|
||||
/** 使用该标签的素材 id,按 id 稳定升序,与 manifest 中的写入顺序无关。 */
|
||||
assetIds: string[];
|
||||
}
|
||||
|
||||
type AssetTagSource = Pick<GameCreationAppAssetManifestEntry, 'id' | 'tags'>;
|
||||
|
||||
/**
|
||||
* 派生标签库:按标签去重、统计使用次数,并给出稳定总序。
|
||||
*
|
||||
* 排序口径:使用次数降序 → 标签 `zh-CN` 本地化升序。
|
||||
* 两条规则都能在任意输入顺序下复现同一结果,因此标签筛选 UI 不会因为
|
||||
* manifest 里素材顺序变化而抖动。
|
||||
*/
|
||||
export function buildGameCreationAppAssetTagLibrary(
|
||||
assets: readonly AssetTagSource[],
|
||||
): GameCreationAppAssetTagLibraryEntry[] {
|
||||
const byTag = new Map<string, string[]>();
|
||||
for (const asset of assets) {
|
||||
// 归一化口径沿用写入路径:去空白、丢空串、同素材内去重。
|
||||
for (const tag of normalizeGameCreationAppAssetTags(
|
||||
gameCreationAppAssetTags(asset),
|
||||
)) {
|
||||
const assetIds = byTag.get(tag);
|
||||
if (!assetIds) {
|
||||
byTag.set(tag, [asset.id]);
|
||||
continue;
|
||||
}
|
||||
// 同一条素材重复写同一个标签只算一次。
|
||||
if (!assetIds.includes(asset.id)) {
|
||||
assetIds.push(asset.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byTag, ([tag, assetIds]) => ({
|
||||
tag,
|
||||
assetCount: assetIds.length,
|
||||
assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
|
||||
})).sort(
|
||||
(left, right) =>
|
||||
right.assetCount - left.assetCount ||
|
||||
left.tag.localeCompare(right.tag, 'zh-CN'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
|
||||
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
|
||||
*/
|
||||
export function assetTagsMatchSelection(
|
||||
tags: readonly string[] | undefined,
|
||||
selectedTags: readonly string[],
|
||||
): boolean {
|
||||
if (selectedTags.length === 0) return true;
|
||||
const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
|
||||
return selectedTags.every((tag) => normalized.has(tag));
|
||||
}
|
||||
|
||||
/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */
|
||||
export function gameCreationAppAssetMatchesTags(
|
||||
asset: Pick<GameCreationAppAssetManifestEntry, 'tags'>,
|
||||
selectedTags: readonly string[],
|
||||
): boolean {
|
||||
return assetTagsMatchSelection(gameCreationAppAssetTags(asset), selectedTags);
|
||||
}
|
||||
@@ -60,6 +60,11 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onPickReferenceFromCanvas?: () => void;
|
||||
/**
|
||||
* 从项目素材库选择参考图。传入时参考图来源菜单多一项,
|
||||
* 点击由宿主打开独立的参考图选择弹窗。
|
||||
*/
|
||||
onRequestProjectAssetPicker?: () => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
@@ -115,6 +120,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onPickReferenceFromCanvas,
|
||||
onRequestProjectAssetPicker,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
@@ -419,6 +425,17 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
{onRequestProjectAssetPicker ? (
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(false);
|
||||
onRequestProjectAssetPicker();
|
||||
}}
|
||||
>
|
||||
从项目素材中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -1465,6 +1465,7 @@ export function ImageCanvasEditorView({
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
projectAssets: assets,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
|
||||
@@ -173,6 +173,8 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
setIsPickingUiDesignSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
/** 参考图来源菜单里的「从项目素材中选择」;不传则不显示该项。 */
|
||||
onRequestProjectAssetPicker?: () => void;
|
||||
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
|
||||
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
|
||||
onSubmitQuickEdit: () => void;
|
||||
@@ -1489,6 +1491,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
onRequestProjectAssetPicker,
|
||||
onSubmitImageGeneration,
|
||||
onSubmitIconSpritesheetGeneration,
|
||||
onSubmitQuickEdit,
|
||||
@@ -1563,6 +1566,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRequestProjectAssetPicker={onRequestProjectAssetPicker}
|
||||
onToggleReferenceMenu={() =>
|
||||
setIsGenerationReferenceMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
CharacterAnimationPanelState,
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
QuickEditPanelState,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
SPEC_FRAME_ORIGINAL_SIZE,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
import { isProjectAssetPickerReference } from './projectAssetReferencePickerModel';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
type SourceGenerationDialogDraftContext = {
|
||||
@@ -2208,6 +2210,38 @@ export function appendGenerationReference(
|
||||
: dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考图弹窗确认:把「从项目素材中选择」进来的参考图整组替换,
|
||||
* 画布点选与上传得到的参考图保持原样。
|
||||
* 素材 id 通过 `sourceAssetId` 随引用进入提交数据,即本次任务的参考图 ID 快照。
|
||||
*/
|
||||
export function replaceProjectAssetPickerReferences(
|
||||
dialog: GenerateDialogState | null,
|
||||
references: CharacterReferenceImage[],
|
||||
): GenerateDialogState | null {
|
||||
if (
|
||||
!dialog ||
|
||||
(dialog.mode !== 'generate' &&
|
||||
dialog.mode !== 'scene' &&
|
||||
dialog.mode !== 'quick-edit' &&
|
||||
dialog.mode !== 'icon' &&
|
||||
dialog.mode !== 'ui-design')
|
||||
) {
|
||||
return dialog;
|
||||
}
|
||||
return {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
generationReferences: appendLimitedImageReferences(
|
||||
(dialog.generationReferences ?? []).filter(
|
||||
(reference) => !isProjectAssetPickerReference(reference),
|
||||
),
|
||||
references,
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPublicationReference(
|
||||
dialog: GenerateDialogState | null,
|
||||
layer: CanvasLayer,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog';
|
||||
|
||||
function createAsset(overrides: Partial<EditorAsset>): EditorAsset {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
label: '素材 1',
|
||||
src: 'https://example.test/asset-1.png',
|
||||
mediaType: 'image',
|
||||
width: 64,
|
||||
height: 64,
|
||||
folderId: 'folder-1',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ASSETS: EditorAsset[] = [
|
||||
createAsset({ id: 'asset-hero', label: '主角立绘' }),
|
||||
createAsset({
|
||||
id: 'asset-town',
|
||||
label: '小镇背景',
|
||||
mediaType: 'video',
|
||||
src: 'https://example.test/town.mp4',
|
||||
}),
|
||||
createAsset({
|
||||
id: 'asset-bgm',
|
||||
label: '背景音乐',
|
||||
mediaType: 'audio',
|
||||
src: 'https://example.test/bgm.mp3',
|
||||
}),
|
||||
];
|
||||
|
||||
describe('ImageCanvasProjectAssetPickerDialog', () => {
|
||||
it('selects several assets, shows the count and returns the id snapshot', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={[]}
|
||||
onCancel={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: '选择参考图小镇背景' }));
|
||||
|
||||
expect(screen.getByText('已选 2 个')).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('option', { name: '选择参考图主角立绘' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认选择参考图' }));
|
||||
expect(onConfirm).toHaveBeenCalledWith(['asset-hero', 'asset-town']);
|
||||
});
|
||||
|
||||
it('clears only the current selection and keeps cancel free of side effects', () => {
|
||||
const onCancel = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-bgm']}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('已选 1 个')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||
expect(screen.getByText('已选 0 个')).toBeTruthy();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deselects one asset from the selected chips', () => {
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero', 'asset-town']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedRow = screen.getByLabelText('已选参考图');
|
||||
fireEvent.click(
|
||||
within(selectedRow).getByRole('button', { name: '取消参考图主角立绘' }),
|
||||
);
|
||||
|
||||
expect(screen.getByText('已选 1 个')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('filters by category and search text with dialog-local state', () => {
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={[]}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '视频' }));
|
||||
expect(
|
||||
screen.queryByRole('option', { name: '选择参考图主角立绘' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('option', { name: '选择参考图小镇背景' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '全部' }));
|
||||
fireEvent.change(screen.getByLabelText('搜索参考图素材'), {
|
||||
target: { value: '音乐' },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('option', { name: '选择参考图背景音乐' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: '选择参考图小镇背景' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('restarts from the caller-provided selection each time it opens', () => {
|
||||
const { rerender } = render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||
expect(screen.getByText('已选 0 个')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={false}
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('已选 1 个')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty state when the project has no registered assets', () => {
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={[]}
|
||||
selectedAssetIds={[]}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS,
|
||||
projectAssetMatchesPickerFilter,
|
||||
type ProjectAssetPickerCategory,
|
||||
projectAssetPickerCategory,
|
||||
} from './projectAssetReferencePickerModel';
|
||||
|
||||
type ImageCanvasProjectAssetPickerDialogProps = {
|
||||
open: boolean;
|
||||
/** 项目已登记素材;弹窗只负责选择,不改素材本身。 */
|
||||
assets: readonly EditorAsset[];
|
||||
/** 打开时的初始选择,取消时原选择保持不变。 */
|
||||
selectedAssetIds: readonly string[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (assetIds: string[]) => void;
|
||||
};
|
||||
|
||||
function assetIcon(asset: EditorAsset) {
|
||||
const category = projectAssetPickerCategory(asset);
|
||||
if (category === 'video') {
|
||||
return <Video className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
if (category === 'audio') {
|
||||
return <Music className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考图选择弹窗。
|
||||
*
|
||||
* 画布筛选条在这里被完整复用,但筛选状态由本弹窗自己持有,
|
||||
* 与资源画布、`@` 面板互不影响。确认时只回传素材 id 列表,
|
||||
* 由调用方把 id 快照写进本次提交数据。
|
||||
*/
|
||||
export function ImageCanvasProjectAssetPickerDialog({
|
||||
open,
|
||||
assets,
|
||||
selectedAssetIds,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ImageCanvasProjectAssetPickerDialogProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
|
||||
const [selection, setSelection] = useState<string[]>([]);
|
||||
|
||||
// 每次打开都从调用方给的初始选择重新开始,取消不写回。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery('');
|
||||
setCategory('all');
|
||||
setSelection([...selectedAssetIds]);
|
||||
}, [open, selectedAssetIds]);
|
||||
|
||||
const visibleAssets = useMemo(
|
||||
() =>
|
||||
assets.filter((asset) =>
|
||||
projectAssetMatchesPickerFilter(asset, { category, query }),
|
||||
),
|
||||
[assets, category, query],
|
||||
);
|
||||
const assetById = useMemo(
|
||||
() => new Map(assets.map((asset) => [asset.id, asset])),
|
||||
[assets],
|
||||
);
|
||||
const selectedAssets = selection.flatMap((assetId) => {
|
||||
const asset = assetById.get(assetId);
|
||||
return asset ? [asset] : [];
|
||||
});
|
||||
|
||||
function toggleAsset(assetId: string) {
|
||||
setSelection((current) =>
|
||||
current.includes(assetId)
|
||||
? current.filter((item) => item !== assetId)
|
||||
: [...current, assetId],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="选择参考图"
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel="关闭选择参考图"
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label="确认选择参考图"
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selectedAssets.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5" aria-label="已选参考图">
|
||||
{selectedAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
aria-label={`取消参考图${asset.label}`}
|
||||
className="platform-category-chip gap-1 px-2 text-xs"
|
||||
onClick={() => toggleAsset(asset.id)}
|
||||
>
|
||||
{assetIcon(asset)}
|
||||
<span>{asset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="参考图筛选"
|
||||
search={{
|
||||
value: query,
|
||||
label: '搜索参考图素材',
|
||||
placeholder: '搜索素材名称',
|
||||
onChange: setQuery,
|
||||
}}
|
||||
categoryItems={PROJECT_ASSET_PICKER_CATEGORY_OPTIONS}
|
||||
activeCategoryId={category}
|
||||
onCategoryChange={(nextCategory) => setCategory(nextCategory)}
|
||||
className="mb-3"
|
||||
/>
|
||||
{visibleAssets.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
{assets.length === 0 ? '当前项目还没有已登记素材' : '没有匹配的素材'}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"
|
||||
role="listbox"
|
||||
aria-label="参考图素材"
|
||||
aria-multiselectable
|
||||
>
|
||||
{visibleAssets.map((asset) => {
|
||||
const selected = selection.includes(asset.id);
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`选择参考图${asset.label}`}
|
||||
className={[
|
||||
'relative flex min-h-[7.5rem] flex-col overflow-hidden rounded-[0.9rem] border text-left transition',
|
||||
selected
|
||||
? 'border-[var(--platform-accent)] bg-white shadow-sm'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:bg-white',
|
||||
].join(' ')}
|
||||
onClick={() => toggleAsset(asset.id)}
|
||||
>
|
||||
<span className="flex h-20 w-full items-center justify-center overflow-hidden bg-white/60">
|
||||
{projectAssetPickerCategory(asset) === 'image' ? (
|
||||
<img
|
||||
src={asset.thumbnailSrc || asset.src}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
assetIcon(asset)
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1 px-2 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--platform-text-strong)]">
|
||||
{asset.label}
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{visibleAssets.length > 0 ? (
|
||||
<p className="mt-2 text-center text-xs text-[var(--platform-text-base)]">
|
||||
<Search className="mr-1 inline h-3 w-3" aria-hidden="true" />
|
||||
{visibleAssets.length} / {assets.length}
|
||||
</p>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAsset,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { replaceProjectAssetPickerReferences } from './ImageCanvasGenerationDialogModel';
|
||||
import { createGenerationInputReference } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS,
|
||||
projectAssetMatchesPickerFilter,
|
||||
projectAssetPickerCategory,
|
||||
projectAssetPickerReferences,
|
||||
} from './projectAssetReferencePickerModel';
|
||||
|
||||
function createAsset(overrides: Partial<EditorAsset>): EditorAsset {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
label: '素材 1',
|
||||
src: 'https://example.test/asset-1.png',
|
||||
mediaType: 'image',
|
||||
width: 64,
|
||||
height: 64,
|
||||
folderId: 'folder-1',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('参考图弹窗素材筛选与快照', () => {
|
||||
it('derives the category from the asset media type', () => {
|
||||
expect(projectAssetPickerCategory({ mediaType: 'image' })).toBe('image');
|
||||
expect(projectAssetPickerCategory({ mediaType: 'video' })).toBe('video');
|
||||
expect(projectAssetPickerCategory({ mediaType: 'audio' })).toBe('audio');
|
||||
expect(projectAssetPickerCategory({})).toBe('image');
|
||||
expect(
|
||||
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS.map((item) => item.id),
|
||||
).toEqual(['all', 'image', 'video', 'audio']);
|
||||
});
|
||||
|
||||
it('stacks the category filter with the search query', () => {
|
||||
const asset = createAsset({ label: '小镇背景', assetKind: 'scene' });
|
||||
|
||||
expect(
|
||||
projectAssetMatchesPickerFilter(asset, { category: 'all', query: '' }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
projectAssetMatchesPickerFilter(asset, {
|
||||
category: 'image',
|
||||
query: '小镇',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
projectAssetMatchesPickerFilter(asset, {
|
||||
category: 'image',
|
||||
query: 'scene',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
projectAssetMatchesPickerFilter(asset, {
|
||||
category: 'audio',
|
||||
query: '小镇',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
projectAssetMatchesPickerFilter(asset, {
|
||||
category: 'all',
|
||||
query: '主角',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds references that carry the asset id snapshot', () => {
|
||||
const assets = [
|
||||
createAsset({
|
||||
id: 'asset-hero',
|
||||
label: '主角立绘',
|
||||
objectKey: 'oss/hero.png',
|
||||
}),
|
||||
createAsset({
|
||||
id: 'asset-bgm',
|
||||
label: '背景音乐',
|
||||
mediaType: 'audio',
|
||||
sourceResourceId: 'resource-bgm',
|
||||
}),
|
||||
];
|
||||
|
||||
const references = projectAssetPickerReferences(assets, [
|
||||
'asset-hero',
|
||||
'asset-bgm',
|
||||
'asset-missing',
|
||||
]);
|
||||
|
||||
expect(references).toEqual([
|
||||
{
|
||||
id: 'project-asset-asset-hero',
|
||||
label: '主角立绘',
|
||||
src: 'oss/hero.png',
|
||||
mediaType: 'image',
|
||||
objectKey: 'oss/hero.png',
|
||||
resourceId: null,
|
||||
sourceAssetId: 'asset-hero',
|
||||
},
|
||||
{
|
||||
id: 'project-asset-asset-bgm',
|
||||
label: '背景音乐',
|
||||
src: 'https://example.test/asset-1.png',
|
||||
mediaType: 'audio',
|
||||
resourceId: 'resource-bgm',
|
||||
sourceAssetId: 'asset-bgm',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('records the selected asset ids as the submitted task reference snapshot', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-hero', label: '主角立绘' }),
|
||||
createAsset({ id: 'asset-town', label: '小镇背景' }),
|
||||
];
|
||||
const references = projectAssetPickerReferences(assets, [
|
||||
'asset-hero',
|
||||
'asset-town',
|
||||
]);
|
||||
|
||||
const snapshot = references.flatMap((reference) =>
|
||||
createGenerationInputReference('参考图', reference),
|
||||
);
|
||||
|
||||
expect(snapshot).toEqual([
|
||||
{
|
||||
title: '参考图',
|
||||
label: '主角立绘',
|
||||
refType: 'asset',
|
||||
refId: 'asset-hero',
|
||||
},
|
||||
{
|
||||
title: '参考图',
|
||||
label: '小镇背景',
|
||||
refType: 'asset',
|
||||
refId: 'asset-town',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no references and dedupes repeated ids', () => {
|
||||
const assets = [createAsset({ id: 'asset-hero', label: '主角立绘' })];
|
||||
|
||||
expect(projectAssetPickerReferences(assets, [])).toEqual([]);
|
||||
expect(
|
||||
projectAssetPickerReferences(assets, ['asset-hero', 'asset-hero']),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaces the library-sourced references and keeps canvas-picked ones', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-hero', label: '主角立绘' }),
|
||||
createAsset({ id: 'asset-town', label: '小镇背景' }),
|
||||
];
|
||||
const dialog = {
|
||||
mode: 'generate',
|
||||
prompt: '画一张封面',
|
||||
status: 'idle',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'canvas-layer-1',
|
||||
label: '画布点选',
|
||||
src: 'https://example.test/canvas.png',
|
||||
mediaType: 'image',
|
||||
},
|
||||
{
|
||||
id: 'project-asset-asset-old',
|
||||
label: '已移除素材',
|
||||
src: 'https://example.test/old.png',
|
||||
mediaType: 'image',
|
||||
sourceAssetId: 'asset-old',
|
||||
},
|
||||
],
|
||||
} satisfies GenerateDialogState;
|
||||
|
||||
const next = replaceProjectAssetPickerReferences(
|
||||
dialog,
|
||||
projectAssetPickerReferences(assets, ['asset-hero', 'asset-town']),
|
||||
);
|
||||
|
||||
expect(
|
||||
next?.generationReferences?.map((reference) => reference.id),
|
||||
).toEqual([
|
||||
'canvas-layer-1',
|
||||
'project-asset-asset-hero',
|
||||
'project-asset-asset-town',
|
||||
]);
|
||||
expect(next?.composerOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves dialogs outside the reference-composer modes untouched', () => {
|
||||
const dialog = {
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
} satisfies GenerateDialogState;
|
||||
|
||||
expect(replaceProjectAssetPickerReferences(dialog, [])).toBe(dialog);
|
||||
expect(replaceProjectAssetPickerReferences(null, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PlatformResourceFilterOption } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import {
|
||||
type CharacterReferenceImage,
|
||||
type EditorAsset,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
/**
|
||||
* 参考图弹窗的分类口径:只按素材的媒体类型分组,与画布的「功能分类」各自独立
|
||||
* (画布用 manifest `category`,这里是编辑器素材库自己的可读分类)。
|
||||
*/
|
||||
export type ProjectAssetPickerMediaCategory = 'image' | 'video' | 'audio';
|
||||
|
||||
export type ProjectAssetPickerCategory =
|
||||
| 'all'
|
||||
| ProjectAssetPickerMediaCategory;
|
||||
|
||||
export const PROJECT_ASSET_PICKER_CATEGORY_OPTIONS: readonly PlatformResourceFilterOption<ProjectAssetPickerCategory>[] =
|
||||
[
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'image', label: '图片' },
|
||||
{ id: 'video', label: '视频' },
|
||||
{ id: 'audio', label: '音频' },
|
||||
];
|
||||
|
||||
export function projectAssetPickerCategory(
|
||||
asset: Pick<EditorAsset, 'mediaType'>,
|
||||
): ProjectAssetPickerMediaCategory {
|
||||
const mediaType = asset.mediaType ?? 'image';
|
||||
if (mediaType === 'video') return 'video';
|
||||
if (mediaType === 'audio') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗筛选判据:分类 + 搜索词,两者叠加。
|
||||
* 与资源画布、`@` 面板的筛选状态无关,调用方各自持有输入值。
|
||||
*/
|
||||
export function projectAssetMatchesPickerFilter(
|
||||
asset: EditorAsset,
|
||||
{ category, query }: { category: ProjectAssetPickerCategory; query: string },
|
||||
): boolean {
|
||||
if (category !== 'all' && projectAssetPickerCategory(asset) !== category) {
|
||||
return false;
|
||||
}
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return true;
|
||||
return [asset.label, asset.assetKind ?? ''].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目素材弹窗加进参考图列表的引用 id 前缀。
|
||||
* 用于在确认时整组替换,避免画布点选 / 上传来的参考图被一并清掉。
|
||||
*/
|
||||
export const PROJECT_ASSET_REFERENCE_ID_PREFIX = 'project-asset-';
|
||||
|
||||
export function isProjectAssetPickerReference(reference: {
|
||||
id: string;
|
||||
}): boolean {
|
||||
return reference.id.startsWith(PROJECT_ASSET_REFERENCE_ID_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把弹窗里选中的素材转成参考图。`sourceAssetId` 用素材自身 id,
|
||||
* 因此提交时 `createGenerationInputReference` 会按 `asset` 记下这份 ID 快照,
|
||||
* 源素材后续变化不影响已提交任务。
|
||||
*/
|
||||
export function projectAssetPickerReferences(
|
||||
assets: readonly EditorAsset[],
|
||||
selectedAssetIds: readonly string[],
|
||||
): CharacterReferenceImage[] {
|
||||
if (selectedAssetIds.length === 0) return [];
|
||||
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
const references: CharacterReferenceImage[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const assetId of selectedAssetIds) {
|
||||
const asset = assetById.get(assetId);
|
||||
if (!asset || seen.has(asset.id)) continue;
|
||||
seen.add(asset.id);
|
||||
references.push({
|
||||
id: `${PROJECT_ASSET_REFERENCE_ID_PREFIX}${asset.id}`,
|
||||
label: asset.label,
|
||||
src: asset.objectKey?.trim() || asset.src,
|
||||
mediaType: asset.mediaType ?? 'image',
|
||||
...(asset.objectKey ? { objectKey: asset.objectKey } : {}),
|
||||
...(asset.assetObjectId ? { assetObjectId: asset.assetObjectId } : {}),
|
||||
resourceId: asset.sourceResourceId ?? null,
|
||||
sourceAssetId: asset.id,
|
||||
});
|
||||
}
|
||||
return references;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
CanvasViewport,
|
||||
EditorAsset,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
@@ -34,6 +36,7 @@ import type {
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
import { replaceProjectAssetPickerReferences } from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
resolveCharacterAnimationPanelStyle,
|
||||
resolveCropExpandPanelStyle,
|
||||
@@ -42,7 +45,12 @@ import {
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditPanelStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog';
|
||||
import { PUBLICATION_MATERIALS_WORKFLOWS } from './ImageCanvasPublicationMaterialsModel';
|
||||
import {
|
||||
isProjectAssetPickerReference,
|
||||
projectAssetPickerReferences,
|
||||
} from './projectAssetReferencePickerModel';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
|
||||
type CanvasGenerationDialogUpdater = (
|
||||
@@ -103,6 +111,8 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
/** 项目已登记素材,用于参考图弹窗;为空时参考图来源菜单不出现该入口。 */
|
||||
projectAssets?: readonly EditorAsset[];
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
|
||||
projectId?: string | null;
|
||||
@@ -192,6 +202,7 @@ export function useImageCanvasGenerationSurface({
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
projectAssets = [],
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
@@ -375,6 +386,36 @@ export function useImageCanvasGenerationSurface({
|
||||
[generationWorkflow, openToolbarOptionMenu, setActiveTool],
|
||||
);
|
||||
|
||||
/**
|
||||
* 参考图弹窗的开关与初始选择。选择列表来自「项目素材」这一组引用,
|
||||
* 与画布点选 / 上传来的参考图互不覆盖。
|
||||
*/
|
||||
const [projectAssetPickerOpen, setProjectAssetPickerOpen] = useState(false);
|
||||
const projectAssetPickerSelectedIds = (
|
||||
generateDialog?.generationReferences ?? []
|
||||
)
|
||||
.filter((reference) => isProjectAssetPickerReference(reference))
|
||||
.flatMap((reference) => {
|
||||
const assetId = reference.sourceAssetId?.trim();
|
||||
return assetId ? [assetId] : [];
|
||||
});
|
||||
const applyProjectAssetPickerSelection = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
const references = projectAssetPickerReferences(projectAssets, assetIds);
|
||||
const nonImageCount = references.filter(
|
||||
(reference) => (reference.mediaType ?? 'image') !== 'image',
|
||||
).length;
|
||||
if (nonImageCount > 0) {
|
||||
generationWorkflow.showGenerationWarning('参考图只支持图片素材');
|
||||
}
|
||||
setGenerateDialog((currentDialog) =>
|
||||
replaceProjectAssetPickerReferences(currentDialog, references),
|
||||
);
|
||||
setProjectAssetPickerOpen(false);
|
||||
},
|
||||
[generationWorkflow, projectAssets, setGenerateDialog],
|
||||
);
|
||||
|
||||
const generationComposerNode = (
|
||||
<>
|
||||
{generationWorkflow.generationWarning ? (
|
||||
@@ -502,6 +543,20 @@ export function useImageCanvasGenerationSurface({
|
||||
}
|
||||
onOpenSpecDialog={generationWorkflow.openSpecDialog}
|
||||
onRequestUpload={requestUpload}
|
||||
onRequestProjectAssetPicker={
|
||||
projectAssets.length > 0 &&
|
||||
(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'scene' ||
|
||||
generateDialog?.mode === 'quick-edit')
|
||||
? () => {
|
||||
generationWorkflow.setIsGenerationReferenceMenuOpen(false);
|
||||
generationWorkflow.setIsPickingGenerationReferenceFromCanvas(
|
||||
false,
|
||||
);
|
||||
setProjectAssetPickerOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSubmitImageGeneration={(dialog) =>
|
||||
void generationWorkflow.submitImageGeneration(dialog)
|
||||
}
|
||||
@@ -581,6 +636,13 @@ export function useImageCanvasGenerationSurface({
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={projectAssetPickerOpen}
|
||||
assets={projectAssets}
|
||||
selectedAssetIds={projectAssetPickerSelectedIds}
|
||||
onCancel={() => setProjectAssetPickerOpen(false)}
|
||||
onConfirm={applyProjectAssetPickerSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user