Merge branch 'feat/agc-v3-c7-version-switch' into feat/agc-canvas-resource-workbench-v3

This commit is contained in:
2026-09-10 15:49:46 +08:00
24 changed files with 2316 additions and 23 deletions
+6 -3
View File
@@ -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>
);
}
@@ -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();
}
+77
View File
@@ -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}
/>
</>
);