diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d8cbe4035..be4606274 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -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) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index c75b73aef..289614325 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -84,6 +84,11 @@ export function WorkspaceLauncherShell({ const activeProjectContextRef = useRef(currentProjectContext); const manifestMergeRef = useRef(null); activeProjectContextRef.current = currentProjectContext; + /** + * C7 当前游戏版本:唯一一份版本状态,同时喂给资源画布(「当前使用」高亮)与 + * `@` 面板(「当前版本素材」页签)。`null` 表示回退到 manifest 中最新的版本。 + */ + const [activeVersionId, setActiveVersionId] = useState(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={ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 8d8283eba..d613fcc59 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -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; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx new file mode 100644 index 000000000..91c7e652e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx @@ -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(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 ( +
+ + {open + ? createPortal( +
+ {versions.map((version) => { + const selected = version.versionId === currentVersion.versionId; + return ( + + ); + })} +
, + document.body, + ) + : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts index e74892ba6..b65737cf1 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts @@ -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, +) { + 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, + activeVersionId: string | null = null, ): Set { - const currentVersion = (manifest.versions ?? []).at(-1); + const currentVersion = resolveActiveIterationVersion( + manifest.versions, + activeVersionId, + ); if (!currentVersion) { return new Set(); } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d32d7bb54..585e696c5 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx new file mode 100644 index 000000000..71b54e108 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx @@ -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 ( + +
+
+

重命名素材

+

{asset.localPath}

+
+ +
+
+ setDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !renaming && draft.trim() !== '') { + event.preventDefault(); + onConfirm(draft); + } + }} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + 取消 + + onConfirm(draft)} + disabled={renaming || draft.trim() === ''} + > + 重命名 + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ed5e575aa..979b6044c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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( defaultResourceCanvasCategoryFilters, ); + /** + * 画布标签筛选:与「按依赖 / 按类型」无关,是画布级的收窄条件; + * 与 `@` 面板、参考图弹窗的筛选状态互相独立。 + */ + const [resourceCanvasActiveTags, setResourceCanvasActiveTags] = useState< + string[] + >([]); const [resourceCanvasViewports, setResourceCanvasViewports] = useState(defaultResourceCanvasViewports); const resourceCanvasViewportTargetsRef = useRef( @@ -1214,6 +1239,14 @@ export default function ProjectDevelopmentView({ useState(false); const [resourceClassificationAssetId, setResourceClassificationAssetId] = useState(null); + /** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */ + const [resourceRenameAssetId, setResourceRenameAssetId] = useState< + string | null + >(null); + const [resourceRenaming, setResourceRenaming] = useState(false); + const [resourceRenameError, setResourceRenameError] = useState( + null, + ); const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = useState>(() => 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( + '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({ 分类与标签 ) : null} + {selectedResource?.manifestAssetId ? ( + } + onClick={() => { + setResourceRenameError(null); + setResourceRenameAssetId( + selectedResource.manifestAssetId, + ); + }} + > + 重命名 + + ) : null} } onOpenQuickEditPanel={openResourceQuickEditPanel} @@ -5064,16 +5204,21 @@ export default function ProjectDevelopmentView({ }} /> - { + setResourceCanvasActiveTags((current) => + current.includes(tag) + ? current.filter((item) => item !== tag) + : [...current, tag], + ); + }} + className="game-resource-category-filter" /> {resourceWorkbenchNotice ? (
@@ -5371,6 +5516,11 @@ export default function ProjectDevelopmentView({ ) : (
+
{embeddedPreviewUrl ? ( ) : null} + {resourceRenameAsset ? ( + { + setResourceRenameError(null); + setResourceRenameAssetId(null); + }} + onConfirm={(newFileName) => void confirmResourceRename(newFileName)} + /> + ) : null} {resourceRecoveryPanelOpen ? (
{ + 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')}`, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx new file mode 100644 index 000000000..ecf4f5ea0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx @@ -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(); + + 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, +) { + 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(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx new file mode 100644 index 000000000..eb7a98c86 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -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(); + + 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>) { + 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); + }); +}); diff --git a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx new file mode 100644 index 000000000..e7d459d38 --- /dev/null +++ b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx @@ -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( + {}, + }} + 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( + , + ); + + 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( + {}} + />, + ); + + expect(screen.queryByRole('searchbox')).toBeNull(); + expect(screen.queryByRole('group', { name: '素材筛选标签' })).toBeNull(); + expect(screen.getByRole('button', { name: '全部' })).toBeTruthy(); + }); +}); diff --git a/packages/shared/src/components/PlatformResourceFilterBar.tsx b/packages/shared/src/components/PlatformResourceFilterBar.tsx new file mode 100644 index 000000000..7d24f96f8 --- /dev/null +++ b/packages/shared/src/components/PlatformResourceFilterBar.tsx @@ -0,0 +1,131 @@ +import { Search, Tag } from 'lucide-react'; +import type { Ref } from 'react'; + +import { PlatformSegmentedTabs } from './PlatformSegmentedTabs'; + +export type PlatformResourceFilterOption = { + 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; +}; + +export type PlatformResourceFilterBarProps = { + /** 控件组无障碍名称,例如「资源筛选」。 */ + ariaLabel: string; + /** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */ + search?: PlatformResourceFilterSearch; + /** 功能分类选项,通常来自唯一的分类口径常量。 */ + categoryItems: readonly PlatformResourceFilterOption[]; + 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({ + ariaLabel, + search, + categoryItems, + activeCategoryId, + onCategoryChange, + tagItems, + activeTags = [], + onToggleTag, + className, +}: PlatformResourceFilterBarProps) { + const tagOptions = tagItems ?? []; + + return ( +
+ {search ? ( + + ) : null} + + {tagOptions.length > 0 ? ( +
+ {tagOptions.map((option) => { + const active = activeTags.includes(option.tag); + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts new file mode 100644 index 000000000..f2bd3b2be --- /dev/null +++ b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts @@ -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, + ); + }); +}); diff --git a/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts new file mode 100644 index 000000000..0ebcfdb71 --- /dev/null +++ b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts @@ -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; + +/** + * 派生标签库:按标签去重、统计使用次数,并给出稳定总序。 + * + * 排序口径:使用次数降序 → 标签 `zh-CN` 本地化升序。 + * 两条规则都能在任意输入顺序下复现同一结果,因此标签筛选 UI 不会因为 + * manifest 里素材顺序变化而抖动。 + */ +export function buildGameCreationAppAssetTagLibrary( + assets: readonly AssetTagSource[], +): GameCreationAppAssetTagLibraryEntry[] { + const byTag = new Map(); + 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, + selectedTags: readonly string[], +): boolean { + return assetTagsMatchSelection(gameCreationAppAssetTags(asset), selectedTags); +} diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 2a58554a1..fad81478e 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -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({ > 上传图片 + {onRequestProjectAssetPicker ? ( + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestProjectAssetPicker(); + }} + > + 从项目素材中选择 + + ) : null} , ) : null} diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 5abf3d6cf..8aca4173e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1465,6 +1465,7 @@ export function ImageCanvasEditorView({ setMetadataLayer, setImageContextMenu, requestUpload, + projectAssets: assets, persistGeneratedAsset, persistUpdatedLayerResource, projectId, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index e0a5406ae..883a8499f 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -173,6 +173,8 @@ type ImageCanvasGenerationComposerViewProps = { setIsPickingUiDesignSpecFromCanvas: Dispatch>; 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) } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b59c7187b..6c6a5fb0f 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -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, diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx new file mode 100644 index 000000000..89dca93c4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx @@ -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 { + 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( + {}} + 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( + , + ); + + 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( + {}} + 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( + {}} + 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( + {}} + onConfirm={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + + rerender( + {}} + onConfirm={() => {}} + />, + ); + rerender( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('shows an empty state when the project has no registered assets', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx new file mode 100644 index 000000000..b49a1a872 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx @@ -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