diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index ede80749c..0b849b9a0 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -94,7 +94,13 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { * 面板自己不持有任何在途状态。 */ onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void; - onClose: () => void; + /** + * 收起浮层。 + * + * 参数是当前草稿:宿主保存它,用户再点开占位卡时接着编辑(关闭 ≠ 丢弃输入,不是空表单)。 + * 提交后的关闭不带草稿——这次输入已经被任务接走,重试身份也在宿主的提交上下文里。 + */ + onClose: (draft?: ResourceCanvasAssetGenerationPanelDraft) => void; }; /** @@ -189,6 +195,15 @@ export function ResourceCanvasAssetGenerationPanelView({ setPrompt(next.text); setReferences(next.references); }; + /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ + const closeWithDraft = () => + onClose({ + prompt, + assetName, + aspectRatio, + imageSize, + references, + }); function submit(event: FormEvent) { event.preventDefault(); @@ -215,6 +230,7 @@ export function ResourceCanvasAssetGenerationPanelView({ imageSize, references, }); + // 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。 onClose(); } @@ -227,7 +243,7 @@ export function ResourceCanvasAssetGenerationPanelView({ @@ -341,7 +357,7 @@ export function ResourceCanvasAssetGenerationPanelView({ 取消 @@ -373,7 +389,7 @@ export function ResourceCanvasAssetGenerationPanelView({ {panelBody} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index f94415b08..a4db6dbf8 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -45,8 +45,36 @@ export type ResourceCanvasGenerationPanelViewProps = { */ variant?: 'modal' | 'floating'; style?: CSSProperties | null; + /** + * 初始草稿(音频 / 背景音乐入口用)。 + * + * 用户收起浮层后草稿由宿主保存,再点开占位卡时从这里灌回来——**不是**空表单, + * 用户不必重打一遍提示词。 + */ + initialDraft?: { + kind: ResourceCanvasGenerationKind; + prompt: string; + assetName: string; + } | null; + /** + * 已绑定的提交身份。 + * + * 同一次草稿的重试必须复用同一对 `operationId` / 幂等键:原生按 operation 记账,换一对就是 + * 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。 + */ + request?: ResourceEditRequestIdentity | null; onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; - onClose: () => void; + /** + * 收起浮层。 + * + * 参数是当前草稿:宿主把它存起来,用户再点开占位卡时能接着编辑(关闭 ≠ 丢弃输入)。 + * 提交成功后的关闭不带草稿(这次输入已经被任务接走)。 + */ + onClose: (draft?: { + kind: ResourceCanvasGenerationKind; + prompt: string; + assetName: string; + }) => void; }; const RESOURCE_GENERATION_ALL_KIND_ITEMS = @@ -77,6 +105,8 @@ export function ResourceCanvasGenerationPanelView({ initialKind, variant = 'modal', style, + initialDraft, + request: boundRequest, onSubmit, onClose, }: ResourceCanvasGenerationPanelViewProps) { @@ -94,16 +124,23 @@ export function ResourceCanvasGenerationPanelView({ const option = resourceCanvasGenerationOption(kind); const panelTitle = allowedOptions.length === 1 ? option.generationLabel : '生成素材'; - const [prompt, setPrompt] = useState(''); - const [assetName, setAssetName] = useState(option.assetName); + const [prompt, setPrompt] = useState(initialDraft?.prompt ?? ''); + const [assetName, setAssetName] = useState( + initialDraft?.assetName ?? option.assetName, + ); const [attempted, setAttempted] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸 // (Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定 // 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。 - const requestRef = useRef(null); + const requestRef = useRef( + boundRequest ?? null, + ); const inputLocked = attempted || submitting; + /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ + const closeWithDraft = () => + onClose({ kind, prompt, assetName: assetName.trim() || option.assetName }); async function submit(event: FormEvent) { event.preventDefault(); @@ -145,7 +182,7 @@ export function ResourceCanvasGenerationPanelView({ @@ -206,7 +243,7 @@ export function ResourceCanvasGenerationPanelView({ {submitting ? '后台运行并关闭' : '取消'} @@ -250,7 +287,7 @@ export function ResourceCanvasGenerationPanelView({ {panelBody} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 2b0ac9634..90a31223b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -154,6 +154,10 @@ export function createResourceCanvasAssetGenerationQueue( assetName: task.assetName, referenceAssetIds: task.referenceAssetIds, outputPath: task.outputPath, + // 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。 + ...(task.targetCategory + ? { targetCategory: task.targetCategory } + : {}), })) as LocalProjectAssetGenerationTaskRecord; let started: LocalProjectAssetGenerationTaskRecord; try { diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index a6c4765f0..577b2665a 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -3,6 +3,7 @@ import { type ResourceCanvasAssetToolAction, resourceCanvasBottomToolActions, } from './resourceCanvasBottomToolbarModel'; +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; /** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */ export type ResourceCanvasAssetGenerationTaskStatus = @@ -56,6 +57,14 @@ export type ResourceCanvasAssetGenerationTask = { * 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。 */ referenceAssetIds: string[]; + /** + * 这次的**入口栏目**:用户点工具时正在看的那个栏目。 + * + * 原生侧按它把新素材直接登记进该栏目(`targetCategory`,可选入参);原生还没支持时它只是 + * 一条提示,落点仍按正式归类后的 section 走(见宿主落点 effect)。前端**不做**伪分类: + * 归类真相只在 manifest 与原生命令里。 + */ + targetCategory: ProjectResourceCanvasCategory | null; outputPath: string | null; projectId: string; /** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */ @@ -165,6 +174,7 @@ export function createResourceCanvasAssetGenerationTask(input: { aspectRatio: string; imageSize: string; referenceAssetIds?: readonly string[]; + targetCategory?: ProjectResourceCanvasCategory | null; outputPath: string | null; projectId: string; nowMillis: number; @@ -188,6 +198,7 @@ export function createResourceCanvasAssetGenerationTask(input: { .filter((assetId) => assetId.length > 0), ), ], + targetCategory: input.targetCategory ?? null, outputPath: input.outputPath, projectId: input.projectId, dispatched: false, @@ -220,6 +231,7 @@ export function restoreResourceCanvasAssetGenerationTask( aspectRatio: '', imageSize: '', referenceAssetIds: [], + targetCategory: null, outputPath: null, projectId: record.projectId, dispatched: true, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts new file mode 100644 index 000000000..f4c01e544 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts @@ -0,0 +1,90 @@ +import type { CanvasViewport } from '../../../../../packages/image-canvas-core/src/types'; +import { normalizeResourceBookViewport } from '../../view/project-development/resourceBookViewport'; + +/** + * 生成浮层/占位的可见性:把「占位卡 + 它下沿的浮层」整块带进画布安全区。 + * + * 只做**最小平移**、不改缩放:用户的缩放预期不能被一次工具点击改掉。与既有 + * `ensureResourceBookContentVisible` / `centerResourceCanvasOnResource` 同一条口径, + * 区别只在于这里要求内容**完整**落在安全区里、并且知道顶栏与底栏要留出来多少。 + * + * 安全区 = 画布减去顶栏(栏目标题栏)与底栏(底部工具栏)后的区域:占位本来就可能被排到 + * 内容下方(真实浏览器 1280x720 上曾出现占位 y≈550、整块浮层落在可视区之外,用户只看到底栏)。 + */ +export type ResourceCanvasGenerationSafeInsets = { + top: number; + bottom: number; + left?: number; + right?: number; +}; + +export function revealResourceCanvasGenerationContent({ + viewport, + content, + canvasSize, + insets, + padding = 12, +}: { + viewport: CanvasViewport; + /** 需要完整可见的内容矩形(画布坐标):占位卡 + 浮层高度 + 两者之间的间隙。 */ + content: { x: number; y: number; width: number; height: number }; + canvasSize: { width: number; height: number }; + insets: ResourceCanvasGenerationSafeInsets; + /** 安全区左右默认留白(顶/底由 insets 覆盖)。 */ + padding?: number; +}): CanvasViewport { + const current = normalizeResourceBookViewport(viewport); + const finite = + Number.isFinite(content.x) && + Number.isFinite(content.y) && + Number.isFinite(content.width) && + Number.isFinite(content.height) && + Number.isFinite(canvasSize.width) && + Number.isFinite(canvasSize.height) && + canvasSize.width > 0 && + canvasSize.height > 0; + if (!finite || content.width <= 0 || content.height <= 0) { + return current; + } + const safeLeft = Math.max(0, insets.left ?? padding); + const safeTop = Math.max(0, insets.top); + const safeRight = Math.max( + safeLeft + 1, + canvasSize.width - Math.max(0, insets.right ?? padding), + ); + const safeBottom = Math.max( + safeTop + 1, + canvasSize.height - Math.max(0, insets.bottom), + ); + const screenLeft = current.x + content.x * current.scale; + const screenTop = current.y + content.y * current.scale; + const screenWidth = Math.max(1, content.width * current.scale); + const screenHeight = Math.max(1, content.height * current.scale); + const screenRight = screenLeft + screenWidth; + const screenBottom = screenTop + screenHeight; + + // 水平:先按左边界对齐,右边越界再整体左推;内容比安全区还宽时以左边界为准。 + let dx = 0; + if (screenRight > safeRight) { + dx = safeRight - screenRight; + } + if (screenLeft + dx < safeLeft) { + dx = safeLeft - screenLeft; + } + // 垂直:同理。占位被排到内容下方时要往上带,浮层顶到顶栏时要往下带。 + let dy = 0; + if (screenBottom > safeBottom) { + dy = safeBottom - screenBottom; + } + if (screenTop + dy < safeTop) { + dy = safeTop - screenTop; + } + if (dx === 0 && dy === 0) { + return current; + } + return { + scale: current.scale, + x: current.x + dx, + y: current.y + dy, + }; +} 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 3a8b75685..607d28766 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 @@ -131,6 +131,7 @@ import { type ResourceCanvasGenerationPlaceholder, } from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; +import { revealResourceCanvasGenerationContent } from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -627,6 +628,47 @@ function resourceCanvasElementSize(element: HTMLElement | null) { }; } +/** + * 顶栏与底栏占掉的安全区(相对画布顶边/底边)。 + * + * 两者都盖在画布上方,占位与生成浮层必须落在它们之间:真实浏览器 1280x720 上出现过占位排到 + * y≈550、整块浮层落在可视区之外,用户只看到底栏。量不到元素(首帧 / 非栏目页)时回退到常量, + * 宁可多留一点边距,也不要让面板贴到栏上。 + */ +const RESOURCE_GENERATION_SAFE_INSET_FALLBACK = { + top: 56, + bottom: 84, +}; +/** 面板高度量不到时的兜底(与浮层的 `max-height` 同量级),宁多留不贴栏。 */ +const RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK = 320; +/** 占位卡与它下沿浮层之间的间隙:与浮层锚点用同一个数。 */ +const RESOURCE_CANVAS_GENERATION_PANEL_GAP = 12; +function resourceGenerationOverlaySafeInsets(element: HTMLElement | null) { + const viewportElement = resourceCanvasViewportElement(element); + if (!viewportElement) { + return RESOURCE_GENERATION_SAFE_INSET_FALLBACK; + } + const canvasRect = viewportElement.getBoundingClientRect(); + const titlebarRect = viewportElement + .querySelector( + '.game-resource-book-scene-titlebar.is-active', + ) + ?.getBoundingClientRect(); + const toolbarRect = viewportElement + .querySelector('.game-resource-bottom-toolbar') + ?.getBoundingClientRect(); + return { + top: + titlebarRect && titlebarRect.height > 0 + ? Math.max(0, titlebarRect.bottom - canvasRect.top) + 10 + : RESOURCE_GENERATION_SAFE_INSET_FALLBACK.top, + bottom: + toolbarRect && toolbarRect.height > 0 + ? Math.max(0, canvasRect.bottom - toolbarRect.top) + 10 + : RESOURCE_GENERATION_SAFE_INSET_FALLBACK.bottom, + }; +} + function resourceCanvasViewportsEqual( left: CanvasViewport, right: CanvasViewport, @@ -1861,19 +1903,36 @@ export default function ProjectDevelopmentView({ viewportScale: () => resourceCanvasSceneViewportRef.current.scale ?? 1, }); /** - * 成功结果的落点意图:占位的最新位置。 + * 成功结果的落点意图:按草稿 ID 各自一条。 * * 生成完成时新资源还没进布局(reconcile 补位发生在下一次渲染),此刻直接 `commitPosition` 会在 * positions 里找不到它、被静默忽略。所以先记下意图,等这张卡真的进了布局再提交落点。 + * + * 用 Map 而不是单槽:多条任务可以在同一时刻收尾,单槽会互相覆盖、只落一条。 + * 也不在这里冻结坐标——真正落点时从占位当前状态取最新位置(用户可能刚把它拖走)。 */ - const resourceGenerationLandingRef = useRef<{ - projectId: string; - draftId: string; - resourceId: string; - category: ProjectResourceCanvasCategory; - x: number; - y: number; - } | null>(null); + const resourceGenerationLandingRef = useRef( + new Map(), + ); + /** + * 音频 / 背景音乐那条链路(`derive_local_project_resource`)的提交身份,按草稿 ID 记。 + * + * 它没有图片队列那本任务账本,提交身份就是面板铸造的 `operationId` + 幂等键:收起来再点开 + * 占位时必须复用同一对,否则重试会变成一次**新的**付费生成。 + */ + const resourceGenerationAudioRequestRef = useRef( + new Map(), + ); + /** 用户主动收起浮层时留下的可再编辑草稿(按草稿 ID 记,提交成功后清掉)。 */ + const resourceGenerationDraftRef = useRef( + new Map< + string, + { kind: ResourceCanvasGenerationKind; prompt: string; assetName: string } + >(), + ); + const resourceAssetGenerationDraftRef = useRef( + new Map(), + ); /** * 「定位到素材」的聚焦请求序号。 * @@ -2829,40 +2888,63 @@ export default function ProjectDevelopmentView({ /** * 结果落点:把成功产物落到它那张占位的**最新位置**。 * - * 等这张卡真的进了布局再提交坐标(生成完成时 reconcile 还没补位,提前提交会被静默忽略); - * 提交后撤掉占位——结果已经接管了它的位置。切项目时落点作废,不把旧项目的坐标写进新项目。 + * 两条判据缺一不可: + * 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作; + * 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源 + * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同 + * (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。 + * + * 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。 */ const removeResourceGenerationPlaceholder = resourceGenerationPlaceholders.remove; useEffect(() => { - const landing = resourceGenerationLandingRef.current; - if (!landing) { + const landings = resourceGenerationLandingRef.current; + if (landings.size === 0) { return; } - if ( - landing.projectId !== manifest.projectId || - landing.projectId !== resourceLayout.projectId - ) { - resourceGenerationLandingRef.current = null; - return; + for (const landing of [...landings.values()]) { + if ( + landing.projectId !== manifest.projectId || + landing.projectId !== resourceLayout.projectId + ) { + landings.delete(landing.draftId); + continue; + } + const placeholder = resourceGenerationPlaceholders.placeholderByDraftId( + landing.draftId, + ); + if (!placeholder) { + landings.delete(landing.draftId); + continue; + } + if (!resourcePositionById.has(landing.resourceId)) { + continue; + } + const projected = resources.find( + (resource) => resource.id === landing.resourceId, + ); + if (!projected) { + continue; + } + landings.delete(landing.draftId); + activeResourceLayout.commitPosition( + landing.resourceId, + projected.category, + // 最新位置:用户可能在生成期间把占位拖到了别处。 + placeholder.x, + placeholder.y, + ); + removeResourceGenerationPlaceholder(landing.draftId); } - if (!resourcePositionById.has(landing.resourceId)) { - return; - } - resourceGenerationLandingRef.current = null; - activeResourceLayout.commitPosition( - landing.resourceId, - landing.category, - landing.x, - landing.y, - ); - removeResourceGenerationPlaceholder(landing.draftId); }, [ activeResourceLayout, manifest.projectId, removeResourceGenerationPlaceholder, + resourceGenerationPlaceholders, resourceLayout.projectId, resourcePositionById, + resources, ]); const selectedVersionBindingResourceIds = useMemo(() => { const selectedVersion = resources.find( @@ -7321,72 +7403,129 @@ export default function ProjectDevelopmentView({ if (!invoke) { throw new Error('生成资源需要在客户端内执行'); } + const placeholder = draftId + ? resourceGenerationPlaceholders.placeholderByDraftId(draftId) + : null; + /* + 同一张占位已经在后台跑:**不允许**用新的 operationId 再发一次——那是一次重复付费生成。 + 同一 operationId 的重试(面板失败后点原请求重试)走原生账本幂等,不在禁止之列。 + */ + if ( + placeholder?.status === 'submitted' && + placeholder.taskId !== input.operationId + ) { + throw new Error('这次生成已在后台继续,请等它结束或失败后再重试'); + } + if (placeholder) { + // 提交身份与被绑定的 operationId 一起记在占位上:关闭面板再点开也用同一 operation 重试。 + resourceGenerationAudioRequestRef.current.set(placeholder.draftId, { + operationId: input.operationId, + idempotencyKey: input.idempotencyKey, + // 提示词是身份的一部分:原生指纹含 prompt,脱开它就变成另一次请求。 + prompt: input.prompt, + }); + resourceGenerationPlaceholders.bindTask( + placeholder.draftId, + input.operationId, + ); + } const option = resourceCanvasGenerationOption(input.kind); const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); - const status = await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ); - if (!Number.isSafeInteger(status.revision) || status.revision < 0) { - throw new Error('项目 revision 无效'); - } - const result = await withPlatformSessionRefresh(() => - invoke( - 'derive_local_project_resource', - { - input: { - projectPath, - expectedProjectId: actionProject.projectId, - expectedProjectRevision: status.revision, - operationId: input.operationId, - idempotencyKey: input.idempotencyKey, - editKind: option.editKind, - generationMode: 'create', - sourceResourceId: resourceCanvasGenerationSourceId( - input.operationId, - ), - sourceAssetId: null, - sourcePath: null, - sourceMediaType: option.sourceMediaType, - sourceSubtype: null, - producerTaskId: null, - sourceVersionId: null, - prompt: input.prompt, - assetName: input.assetName, + try { + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: actionProject.projectId, + expectedProjectRevision: status.revision, + operationId: input.operationId, + idempotencyKey: input.idempotencyKey, + editKind: option.editKind, + generationMode: 'create', + sourceResourceId: resourceCanvasGenerationSourceId( + input.operationId, + ), + sourceAssetId: null, + sourcePath: null, + sourceMediaType: option.sourceMediaType, + sourceSubtype: null, + producerTaskId: null, + sourceVersionId: null, + prompt: input.prompt, + assetName: input.assetName, + }, }, - }, - ), - ); - if ( - !result.asset || - result.manifest.projectId !== actionProject.projectId - ) { - throw new Error('生成资源结果与当前项目不一致'); + ), + ); + if ( + !result.asset || + result.manifest.projectId !== actionProject.projectId + ) { + throw new Error('生成资源结果与当前项目不一致'); + } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + commitId: result.operationId, + }); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: result.operationId, + sessionId: result.operationId, + draftId: result.operationId, + commitId: result.operationId, + projectPath, + projectId: result.manifest.projectId, + focusGeneration: focusGenerationRef.current, + resourceId: `asset:${result.asset.id}`, + completed: false, + }; + setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); + if (placeholder) { + /* + 成功落点:与图片类生成同一条口径——按**正式归类后的 section**提交占位的**最新位置**, + 等新卡进了布局再落(见落点 effect)。这里只记意图,不冻结坐标。 + */ + resourceGenerationLandingRef.current.set(placeholder.draftId, { + projectId: placeholder.projectId, + draftId: placeholder.draftId, + resourceId: `asset:${result.asset.id}`, + }); + resourceGenerationAudioRequestRef.current.delete(placeholder.draftId); + } + setResourceGenerationDraft(null); + } catch (error) { + /* + 失败:占位留在画布上(输入与操作身份都还在,面板自己展示原因),状态收口为失败。 + 重试沿用同一 operationId,命中原生幂等账本——不会重复发起一次付费生成。 + */ + if (placeholder) { + resourceGenerationPlaceholders.failTask( + input.operationId, + error instanceof Error ? error.message : String(error), + ); + } + throw error; } - onManifestChange?.(projectPath, result.manifest, { - projectId: result.manifest.projectId, - revision: result.committedProjectRevision, - source: 'asset-command', - commitId: result.operationId, - }); - activeFocusFlowIdRef.current = flowId; - pendingResourceFocusRef.current = { - flowId, - saveAttemptId: result.operationId, - sessionId: result.operationId, - draftId: result.operationId, - commitId: result.operationId, - projectPath, - projectId: result.manifest.projectId, - focusGeneration: focusGenerationRef.current, - resourceId: `asset:${result.asset.id}`, - completed: false, - }; - setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); - setResourceGenerationDraft(null); - }, - [manifest.projectId, onManifestChange, projectPath], + }, + [ + manifest.projectId, + onManifestChange, + projectPath, + resourceGenerationPlaceholders, + ], ); /** @@ -7515,14 +7654,11 @@ export default function ProjectDevelopmentView({ const landingPlaceholder = resourceGenerationPlaceholders.placeholderByTaskId(settlement.taskId); if (landingPlaceholder) { - resourceGenerationLandingRef.current = { + resourceGenerationLandingRef.current.set(landingPlaceholder.draftId, { projectId: landingPlaceholder.projectId, draftId: landingPlaceholder.draftId, resourceId: `asset:${assetId}`, - category: landingPlaceholder.category, - x: landingPlaceholder.x, - y: landingPlaceholder.y, - }; + }); } let fresh: Awaited< ReturnType @@ -7655,6 +7791,11 @@ export default function ProjectDevelopmentView({ referenceAssetIds: resourceCanvasAssetGenerationReferenceIds( input.references, ), + // 入口栏目随任务带上(原生 `targetCategory` 可选入参)。前端不拿它当分类真相: + // 落点仍按正式归类后的 section 走。 + targetCategory: + resourceGenerationPlaceholders.placeholderByDraftId(draftId) + ?.category ?? null, outputPath: resourceCanvasAssetGenerationOutputPath( action, context.hasIconSpecReference, @@ -7676,6 +7817,8 @@ export default function ProjectDevelopmentView({ dispatchedImmediately, }; setResourceAssetGenerationPanelReopen(null); + // 这次输入已经被任务接走:收起草稿的副本不再需要。 + resourceAssetGenerationDraftRef.current.delete(draftId); // 占位从「待提交」进入「生成中」:任务已经交给后台,关闭浮层不影响它。 resourceGenerationPlaceholders.bindTask(draftId, task.taskId); setResourceAssetGenerationTasksPanelOpen(true); @@ -7712,6 +7855,10 @@ export default function ProjectDevelopmentView({ // 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。 setResourceGenerationDraft(null); setResourceAssetGenerationPanel(null); + // 收起时的草稿与音频提交身份同样是本项目内的记忆,换项目一并作废。 + resourceGenerationAudioRequestRef.current.clear(); + resourceGenerationDraftRef.current.clear(); + resourceAssetGenerationDraftRef.current.clear(); void (async () => { const reportUnavailable = () => { if (!cancelled) { @@ -8070,6 +8217,73 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + /** + * 打开生成浮层时把「占位卡 + 浮层」整块带进可视区。 + * + * 工具点击的落点排在当前栏目内容下方,栏目内容一多就落到视口之外:面板虽然渲染出来了, + * 用户看到的只有底栏(真实浏览器 1280x720 复现过)。这里用最小的视口平移把它带回来,不改缩放 + * (与既有点位定位同一条口径),并留出顶栏 / 底栏的安全区。 + */ + useEffect(() => { + if (!resourceGenerationPanelDraftId) { + return; + } + const placeholder = resourceGenerationPlaceholders.placeholderByDraftId( + resourceGenerationPanelDraftId, + ); + if (!placeholder) { + return; + } + const category: ResourceBookTarget | null = resourceBookOpensAllResources + ? RESOURCE_BOOK_ALL_TARGET + : activePageCategory; + if (!category || resourceBookView !== 'child') { + return; + } + const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current); + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return; + } + const panelElement = resourceCanvasRef.current?.querySelector( + '[data-resource-canvas-generation-floating-panel]', + ); + // 面板高度量得到就用实测值:Lexical 输入区与规格行都会撑高它。 + const panelHeight = Math.max( + 0, + panelElement?.getBoundingClientRect().height ?? + RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK, + ); + const viewport = normalizeResourceBookViewport( + category === RESOURCE_BOOK_ALL_TARGET + ? resourceBookAllViewportRef.current + : resourceCanvasViewportRef.current, + ); + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: + placeholder.height + + RESOURCE_CANVAS_GENERATION_PANEL_GAP + + panelHeight, + }, + canvasSize, + insets: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current), + }); + if (resourceCanvasViewportsEqual(next, viewport)) { + return; + } + setResourceCanvasViewport(category, next); + }, [ + activePageCategory, + resourceBookOpensAllResources, + resourceBookView, + resourceGenerationPanelDraftId, + resourceGenerationPlaceholders, + setResourceCanvasViewport, + ]); /** * 收起生成浮层。 @@ -8169,7 +8383,15 @@ export default function ProjectDevelopmentView({ error: placeholder.error ?? '生成素材失败', }); } else { - setResourceAssetGenerationPanelReopen(null); + // 用户自己收起过浮层:接着编辑同一份草稿,而不是回到空表单。 + const closedDraft = resourceAssetGenerationDraftRef.current.get( + placeholder.draftId, + ); + setResourceAssetGenerationPanelReopen( + closedDraft + ? { draftId: placeholder.draftId, draft: closedDraft, error: '' } + : null, + ); } setResourceAssetGenerationPanel({ action: placeholder.panel.action, @@ -8927,17 +9149,35 @@ export default function ProjectDevelopmentView({ initialKind={resourceGenerationDraft.initialKind} variant="floating" style={resourceGenerationPanelStyle} + // 用户收起过浮层就接着编辑同一份草稿(不是空表单)。 + initialDraft={ + resourceGenerationDraftRef.current.get( + resourceGenerationDraft.draftId, + ) ?? null + } + // 已提交过的草稿复用同一对 operationId / 幂等键,重试不会变成新生成。 + request={ + resourceGenerationAudioRequestRef.current.get( + resourceGenerationDraft.draftId, + ) ?? null + } onSubmit={(input) => submitResourceCanvasGeneration( input, resourceGenerationDraft.draftId, ) } - onClose={() => + onClose={(draft) => { + if (draft) { + resourceGenerationDraftRef.current.set( + resourceGenerationDraft.draftId, + draft, + ); + } closeResourceGenerationFloatingPanel( resourceGenerationDraft.draftId, - ) - } + ); + }} /> ) : null} {resourceAssetGenerationPanel && @@ -8980,7 +9220,14 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationPanel.draftId, ) } - onClose={() => { + onClose={(closedDraft) => { + if (closedDraft) { + // 关闭 ≠ 丢弃输入:草稿按占位归属存下来,点开占位时接着编辑。 + resourceAssetGenerationDraftRef.current.set( + resourceAssetGenerationPanel.draftId, + closedDraft, + ); + } setResourceAssetGenerationPanelReopen((current) => current?.draftId === resourceAssetGenerationPanel.draftId diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts new file mode 100644 index 000000000..b8234ff1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; + +import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; + +const canvasSize = { width: 800, height: 600 }; +const insets = { top: 60, bottom: 90 }; + +describe('生成浮层与占位的可见性', () => { + test('内容已经在安全区内时坐标不变(宿主据此跳过写回)', () => { + const viewport = { x: 0, y: 0, scale: 1 }; + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { x: 40, y: 80, width: 180, height: 200 }, + canvasSize, + insets, + }); + expect(next).toEqual(viewport); + }); + + test('内容落到视口下方时做最小上移,底边贴住底栏安全区', () => { + const viewport = { x: 0, y: 0, scale: 1 }; + // 占位在 y=550、加上浮层后整块落到视口外——真实浏览器上复现过的那一档。 + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { x: 40, y: 550, width: 180, height: 320 }, + canvasSize, + insets, + }); + const safeBottom = canvasSize.height - insets.bottom; + expect(viewport.y + (550 + 320)).toBeGreaterThan(safeBottom); + expect(next.y + 550 + 320).toBe(safeBottom); + expect(next.x).toBe(0); + expect(next.scale).toBe(1); + }); + + test('内容顶出顶栏时下移,内容越出左右边时做最小横移', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 1 }, + content: { x: -300, y: -40, width: 180, height: 120 }, + canvasSize, + insets, + }); + expect(next.y).toBe(insets.top + 40); + expect(next.x).toBe(12 + 300); + }); + + test('缩放参与换算:缩小后的越界按同一口径平移,比例保持不变', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 0.5 }, + content: { x: 40, y: 900, width: 180, height: 320 }, + canvasSize, + insets, + }); + expect(next.scale).toBe(0.5); + const safeBottom = canvasSize.height - insets.bottom; + expect(next.y + (900 + 320) * 0.5).toBe(safeBottom); + }); + + test('内容比安全区还大时以顶边 / 左边对齐,不来回抖', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 1 }, + content: { x: 0, y: 0, width: 2000, height: 1200 }, + canvasSize, + insets, + }); + expect(next).toEqual({ scale: 1, x: 12, y: insets.top }); + }); + + test('尺寸非法或画布未就绪时原样返回', () => { + const viewport = { x: 5, y: 6, scale: 1 }; + expect( + revealResourceCanvasGenerationContent({ + viewport, + content: { x: 0, y: 0, width: 0, height: 0 }, + canvasSize, + insets, + }), + ).toEqual(viewport); + expect( + revealResourceCanvasGenerationContent({ + viewport, + content: { x: 0, y: 0, width: 10, height: 10 }, + canvasSize: { width: 0, height: 0 }, + insets, + }), + ).toEqual(viewport); + }); +});