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 d9443353b..5c36c3d56 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 @@ -22,34 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = { imageSize: string; }; +/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */ +export type ResourceCanvasAssetGenerationPanelDraft = { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; +}; + export type ResourceCanvasAssetGenerationPanelViewProps = { action: ResourceCanvasAssetToolAction; /** - * 在途阶段文案,**由后端任务账本提供**(`phaseDetail`)。 + * 上一次「点击瞬间就失败」带回来的草稿。 * - * 面板不自己编进度:派发前是本地排队的「排队中。」,派发后是后端的「正在生成。」之类。 + * 面板点击即关闭,草稿只活在组件里;重开时由宿主把它传回来,用户改完就能重试。 */ - phaseDetail?: string | null; - onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise; + draft?: ResourceCanvasAssetGenerationPanelDraft; + /** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */ + error?: string | null; + /** + * 提交回调:**同步返回**,面板不等它的结果。 + * + * 受理失败要不要把面板带回来由宿主决定(只有「从未被后端受理」的即时失败才重开), + * 面板自己不持有任何在途状态。 + */ + onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void; onClose: () => void; }; -function assetGenerationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; -} - /** * 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / * 生成 UI 设计图共用)。 * * 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有 - * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。 + * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责。 * - * 生成已经在后端后台跑(`start_local_project_asset_generation` + 项目内任务账本),所以 - * **提交期间面板必须能关**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通,关闭只是把这一份 - * view 卸下来,请求继续在后台跑完并把结果写回项目——关闭**不等于**取消。 + * **点击「生成」即关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布。所以面板里 + * 不存在「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是画布上的 + * 「生成任务」侧栏与工具栏提示条。关闭**不等于**取消:任务照常在后台跑完并把结果写回项目。 + * + * 只有「点击瞬间就失败」(校验 / 权限拒绝 / IPC 立即报错,即后端从未受理)时,宿主才会带着 + * `draft` 与 `error` 把面板重新打开,用户可以直接改后重试。 * * 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC * 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必 @@ -58,45 +71,45 @@ function assetGenerationErrorMessage(error: unknown) { */ export function ResourceCanvasAssetGenerationPanelView({ action, - phaseDetail, + draft, + error: initialError, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { - const [prompt, setPrompt] = useState(''); - const [assetName, setAssetName] = useState(action.assetName); - const [aspectRatio, setAspectRatio] = useState(action.aspectRatio); - const [imageSize, setImageSize] = useState(action.imageSize); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [prompt, setPrompt] = useState(draft?.prompt ?? ''); + const [assetName, setAssetName] = useState( + draft?.assetName ?? action.assetName, + ); + const [aspectRatio, setAspectRatio] = useState( + draft?.aspectRatio ?? action.aspectRatio, + ); + const [imageSize, setImageSize] = useState( + draft?.imageSize ?? action.imageSize, + ); + const [error, setError] = useState(initialError ?? null); // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); - const canSubmit = - !submitting && prompt.trim().length > 0 && assetName.trim().length > 0; + const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0; - async function submit(event: FormEvent) { + function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName || submitting) { + if (!normalizedPrompt || !normalizedAssetName) { return; } - setSubmitting(true); setError(null); - try { - await onSubmit({ - kind: action.assetKind, - prompt: normalizedPrompt, - assetName: normalizedAssetName, - aspectRatio, - imageSize, - }); - } catch (submitError) { - // 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。 - setError(assetGenerationErrorMessage(submitError)); - } finally { - setSubmitting(false); - } + // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 + // (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。 + onSubmit({ + kind: action.assetKind, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + aspectRatio, + imageSize, + }); + onClose(); } return ( @@ -124,7 +137,6 @@ export function ResourceCanvasAssetGenerationPanelView({ setAssetName(event.currentTarget.value)} /> @@ -136,7 +148,6 @@ export function ResourceCanvasAssetGenerationPanelView({ aria-label="生成提示词" rows={6} autoFocus - disabled={submitting} maxLength={promptMaxLength} placeholder={action.promptPlaceholder} value={prompt} @@ -156,7 +167,6 @@ export function ResourceCanvasAssetGenerationPanelView({ columns="threeToSix" gap="sm" size="compact" - disabled={submitting} onChange={setAspectRatio} /> @@ -186,7 +195,6 @@ export function ResourceCanvasAssetGenerationPanelView({ subject={`素材生成提示词(${action.label})`} editKind="image-reference" prompt={prompt} - disabled={submitting} applyPrompt={setPrompt} /> {error ? ( @@ -200,11 +208,11 @@ export function ResourceCanvasAssetGenerationPanelView({ tone="secondary" onClick={onClose} > - {submitting ? '后台运行并关闭' : '取消'} + 取消 diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index d64d6515f..8915d5663 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -1,44 +1,113 @@ -import { X } from 'lucide-react'; +import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS, - type ResourceCanvasAssetGenerationTask, resourceCanvasAssetGenerationElapsedLabel, + type ResourceCanvasAssetGenerationTask, resourceCanvasAssetGenerationTaskElapsedMillis, resourceCanvasAssetGenerationTaskIsTerminal, sortResourceCanvasAssetGenerationTasks, } from './resourceCanvasAssetGenerationTaskModel'; +/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */ +export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20; + export type ResourceCanvasAssetGenerationTasksPanelViewProps = { tasks: readonly ResourceCanvasAssetGenerationTask[]; - onClose: () => void; + /** 侧栏是否展开;折叠时只留贴边把手。 */ + open: boolean; + onToggleOpen: () => void; /** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */ onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void; }; +function taskRow( + task: ResourceCanvasAssetGenerationTask, + nowMillis: number, + onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, +) { + const focusable = task.status === 'completed' && Boolean(task.assetId); + return ( +
  • +
    + {task.assetName} + {task.actionLabel} +
    +
    + + {RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]} + + {task.phaseDetail} + + {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( + resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis), + )}`} + +
    + {task.error ? ( +

    + {task.error} +

    + ) : null} + {focusable ? ( + + ) : null} +
  • + ); +} + /** - * 「生成任务」浮层面板:列出每条图片类生成任务的状态、后端阶段文案、已耗时与素材名。 + * 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。 * - * **它是非模态的**:不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` 的 - * 遮挡判据——生成在后台跑,面板开着的时候画布必须照样能看能用。这也是它和两块生成浮层 - * (提交表单,`ThemedModal` 模态)的关键差别。 + * 形态对齐网页端美术画布的任务侧栏:贴边的独立 ` ); } 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 c2c17179a..87e724bd6 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 @@ -175,10 +175,9 @@ export function createResourceCanvasAssetGenerationQueue( for (;;) { let records: LocalProjectAssetGenerationTaskRecord[]; try { - records = (await deps.invoke( - 'list_local_project_asset_generations', - { projectPath }, - )) as LocalProjectAssetGenerationTaskRecord[]; + records = (await deps.invoke('list_local_project_asset_generations', { + projectPath, + })) as LocalProjectAssetGenerationTaskRecord[]; } catch (error) { // IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛—— // 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。 @@ -228,9 +227,7 @@ export function createResourceCanvasAssetGenerationQueue( } } - function markSettled( - settlement: ResourceCanvasAssetGenerationSettlement, - ) { + function markSettled(settlement: ResourceCanvasAssetGenerationSettlement) { const settled = deps .listTasks() .find((task) => task.taskId === settlement.taskId); 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 58a7000ab..150037530 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 @@ -1,7 +1,7 @@ import { resolveResourceCanvasBottomTools, - resourceCanvasBottomToolActions, type ResourceCanvasAssetToolAction, + resourceCanvasBottomToolActions, } from './resourceCanvasBottomToolbarModel'; /** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */ @@ -200,10 +200,7 @@ export function restoreResourceCanvasAssetGenerationTask( */ export function applyLocalProjectAssetGenerationRecords( tasks: readonly ResourceCanvasAssetGenerationTask[], - records: - | readonly LocalProjectAssetGenerationTaskRecord[] - | null - | undefined, + records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined, ): ResourceCanvasAssetGenerationTask[] { const safeRecords = Array.isArray(records) ? records.filter( 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 b9f4f3f31..0b899f89c 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 @@ -25,8 +25,8 @@ import { Info, Layers, LayoutGrid, - ListFilter, ListChecks, + ListFilter, Maximize2, Minus, Music2, @@ -111,9 +111,23 @@ import { } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { + type ResourceCanvasAssetGenerationPanelDraft, ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + createResourceCanvasAssetGenerationQueue, + mergeResourceCanvasAssetGenerationTasksWithRecords, + type ResourceCanvasAssetGenerationQueue, + type ResourceCanvasAssetGenerationSettlement, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; +import { + createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, + type ResourceCanvasAssetGenerationTask, + resourceCanvasAssetGenerationTaskIsTerminal, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { defaultResourceExportFileName, isResourceCanvasExportable, @@ -331,18 +345,6 @@ import { } from './useProjectResourceCanvasLayout'; import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews'; import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights'; -import { - createResourceCanvasAssetGenerationQueue, - mergeResourceCanvasAssetGenerationTasksWithRecords, - type ResourceCanvasAssetGenerationQueue, - type ResourceCanvasAssetGenerationSettlement, -} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; -import { - createResourceCanvasAssetGenerationTask, - type LocalProjectAssetGenerationTaskRecord, - type ResourceCanvasAssetGenerationTask, -} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; -import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow'; export type { @@ -1587,10 +1589,45 @@ export default function ProjectDevelopmentView({ const resourceAssetGenerationTasksRef = useRef< ResourceCanvasAssetGenerationTask[] >([]); - /** 提交表单当前这次提交对应的任务 id:面板的在途文案要读该任务的后端阶段。 */ - const resourceAssetGenerationPanelTaskIdRef = useRef(null); - const [resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen] = - useState(false); + /** + * 提交面板上一次提交的上下文。 + * + * 面板点击即关闭,草稿只活在组件里,所以「点击瞬间就失败」要把面板带回来时,得从这里取回 + * 那份草稿;`dispatchedImmediately` 用来区分「这次点击本来就该立刻派发」与「排在队列后面 + * 才派发」——只有前者才值得重开面板。 + */ + const resourceAssetGenerationPanelSubmissionRef = useRef<{ + taskId: string; + action: ResourceCanvasAssetToolAction; + draft: ResourceCanvasAssetGenerationPanelDraft; + dispatchedImmediately: boolean; + } | null>(null); + /** 即时失败重开提交面板时带回去的草稿与原因;正常打开时为 null。 */ + const [ + resourceAssetGenerationPanelReopen, + setResourceAssetGenerationPanelReopen, + ] = useState<{ + actionId: string; + draft: ResourceCanvasAssetGenerationPanelDraft; + error: string; + } | null>(null); + const [ + resourceAssetGenerationTasksPanelOpen, + setResourceAssetGenerationTasksPanelOpen, + ] = useState(false); + /** + * 「定位到素材」的聚焦请求序号。 + * + * 聚焦 effect(`resolveResourceFocusIntent` 那条链)的依赖全是画布自身状态,手动点一次定位 + * 不改其中任何一项 → effect 不会重跑,intent 永远没人消费、提示条停在中转文案上。所以每次 + * 点击都要推进这个序号,让「这次请求」成为一个真实的依赖变化。 + */ + const [ + resourceAssetGenerationFocusRequest, + setResourceAssetGenerationFocusRequest, + ] = useState(0); + /** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */ + const resourceWorkbenchNoticeRef = useRef(''); const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] = useState(false); const [resourcePanelNotice, setResourcePanelNotice] = useState(''); @@ -5696,6 +5733,9 @@ export default function ProjectDevelopmentView({ } if (focusedCommitIdsRef.current.has(intent.commitId)) { pendingResourceFocusRef.current = null; + // 这条资源已经聚焦过了:把中转提示一并收掉,否则「生成资源已保存,正在同步资源与布局…」 + // 这类文字会永久留在提示条上。 + setResourceWorkbenchNotice(''); return; } intent.completed = true; @@ -5713,6 +5753,9 @@ export default function ProjectDevelopmentView({ manifest.projectId, projectPath, resources, + // 手动「定位到素材」不改画布任何状态,靠这个序号把「这次定位请求」变成真实的依赖变化; + // 少了它 effect 不会重跑,intent 永远没人消费。 + resourceAssetGenerationFocusRequest, selectResourceCanvasPage, typeLayout.layout.positions, typeLayout.settled, @@ -6806,13 +6849,27 @@ export default function ProjectDevelopmentView({ projectId: manifest.projectId, hasIconSpecReference, onManifestChange, + manifest, + resources, + activePageCategory, }); resourceAssetGenerationContextRef.current = { projectPath, projectId: manifest.projectId, hasIconSpecReference, onManifestChange, + manifest, + resources, + activePageCategory, }; + resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice; + /** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务。 */ + const resourceAssetGenerationInFlightCount = + resourceAssetGenerationTasks.filter( + (task) => + task.projectId === manifest.projectId && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ).length; const replaceResourceAssetGenerationTask = useCallback( (next: ResourceCanvasAssetGenerationTask) => { @@ -6827,11 +6884,15 @@ export default function ProjectDevelopmentView({ ); /** - * 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示。 + * 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示 / 即时失败把提交面板带回来。 * * 后端把生成结果与 manifest 登记都写完才把记录置为终态,所以这里只做「配对读 + 交给 * `onManifestChange`」这条既有链路,再用 `pendingResourceFocusRef` 定位新卡;不重算依赖图、 * 不另写布局逻辑。 + * + * 失败分两类:**后端从未受理**(`record === null`,且这次点击本来就该立刻派发)→ 把提交面板 + * 连草稿一起带回来,错误留在面板里;**受理之后才失败**(生成中失败 / 远端失败 / 轮询超时)→ + * 不重开面板,只在「生成任务」侧栏收口为失败并给一次提示条。 */ const handleResourceAssetGenerationSettlement = useCallback( async (settlement: ResourceCanvasAssetGenerationSettlement) => { @@ -6841,6 +6902,24 @@ export default function ProjectDevelopmentView({ // 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。 return; } + const submission = resourceAssetGenerationPanelSubmissionRef.current; + if (submission?.taskId === settlement.taskId) { + resourceAssetGenerationPanelSubmissionRef.current = null; + if ( + settlement.status === 'failed' && + settlement.record === null && + submission.dispatchedImmediately + ) { + setResourceAssetGenerationPanelReopen({ + actionId: submission.action.id, + draft: submission.draft, + error: settlement.error ?? '生成素材失败', + }); + setResourceAssetGenerationAction(submission.action); + setResourceWorkbenchNotice(''); + return; + } + } if (settlement.status !== 'completed' || !settlement.record?.assetId) { setResourceWorkbenchNotice( `生成素材失败:${settlement.error ?? '未知原因'}`, @@ -6906,11 +6985,6 @@ export default function ProjectDevelopmentView({ completed: false, }; setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); - // 只在「这块表单还是这次提交的」时收面板:用户可能已经关掉它、又打开做第二次提交。 - if (resourceAssetGenerationPanelTaskIdRef.current === settlement.taskId) { - resourceAssetGenerationPanelTaskIdRef.current = null; - setResourceAssetGenerationAction(null); - } }, [], ); @@ -6950,19 +7024,30 @@ export default function ProjectDevelopmentView({ /** * 提交一条图片类生成任务。 * - * 只做「入队」,生成由队列在后台派发与轮询;面板的 `await` 落在该任务的终局上,所以提交 - * 期间关掉面板不等于取消请求,失败也能带着草稿重试。 + * 只做「入队」,生成由队列在后台派发与轮询。**同步返回**:提交面板在点击那一刻就自己关掉了, + * 不等受理、不等排队、不等生成;只有后端从未受理的即时失败才会由收尾回调把面板连草稿一起带回来。 */ const submitResourceAssetGeneration = useCallback( - async ( + ( action: ResourceCanvasAssetToolAction, input: ResourceCanvasAssetGenerationSubmitInput, ) => { const queue = resourceAssetGenerationQueueRef.current; - if (!queue) { - throw new Error('生成任务队列尚未就绪'); - } const context = resourceAssetGenerationContextRef.current; + if (!queue) { + setResourceWorkbenchNotice( + '生成任务队列尚未就绪,请重新打开项目后重试', + ); + return; + } + // 「这次点击本来就该立刻派发」:队列里没有在途任务时才是。排在队列后面才派发的任务即使 + // 提交失败,也不该把面板弹回来打断用户。 + const dispatchedImmediately = + !resourceAssetGenerationTasksRef.current.some( + (task) => + task.dispatched && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ); const task = createResourceCanvasAssetGenerationTask({ taskId: crypto.randomUUID(), action, @@ -6977,9 +7062,25 @@ export default function ProjectDevelopmentView({ projectId: context.projectId, nowMillis: Date.now(), }); - resourceAssetGenerationPanelTaskIdRef.current = task.taskId; + resourceAssetGenerationPanelSubmissionRef.current = { + taskId: task.taskId, + action, + draft: { + prompt: input.prompt, + assetName: input.assetName, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + }, + dispatchedImmediately, + }; + setResourceAssetGenerationPanelReopen(null); setResourceAssetGenerationTasksPanelOpen(true); - await queue.submit(task); + setResourceWorkbenchNotice( + `已提交「${input.assetName}」,生成在后台继续,进度见「生成任务」`, + ); + // 终局由 `onSettled` 收口(成功落卡 / 失败收口 / 即时失败重开面板),这里只吞掉拒绝, + // 避免出现未处理的 Promise 拒绝。 + void queue.submit(task).catch(() => undefined); }, [], ); @@ -7002,7 +7103,8 @@ export default function ProjectDevelopmentView({ const projectId = manifest.projectId; let cancelled = false; setResourceAssetGenerationTasksPanelOpen(false); - resourceAssetGenerationPanelTaskIdRef.current = null; + resourceAssetGenerationPanelSubmissionRef.current = null; + setResourceAssetGenerationPanelReopen(null); void (async () => { const reportUnavailable = () => { if (!cancelled) { @@ -7043,32 +7145,91 @@ export default function ProjectDevelopmentView({ }; }, [manifest.projectId, projectPath]); - /** 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 */ + /** + * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 + * + * **每次点击都必须终局化**:素材不在投影里 / 不在当前栏目 / 被搜索挡住 / 画布还在布局,四种情况 + * 各有结论,不允许留下悬而未决的 intent 与中转提示。所以这里做三件事: + * + * 1. 先按当前投影与清单判一次「这次点击有没有可定位的目标」——没有就直接给可执行结论, + * 连 intent 都不挂(挂上去也没人消费); + * 2. 有目标就挂 intent,并推进 `resourceAssetGenerationFocusRequest`:聚焦 effect 的依赖全是 + * 画布自身状态,不推进这个序号时 effect 不会重跑,点了等于没点(这正是「点了没反应且提示条 + * 永久停在中转文案」的根因); + * 3. 起一个有界兜底:3 秒后仍停在中转文案就收口成可执行提示,绝不把中转态留给用户。 + */ const focusResourceAssetGenerationTask = useCallback( (task: ResourceCanvasAssetGenerationTask) => { - if (!task.assetId) { + const assetId = task.assetId; + if (!assetId) { return; } const context = resourceAssetGenerationContextRef.current; + const resourceId = `asset:${assetId}`; + const target = context.resources.find( + (resource) => resource.id === resourceId, + ); + const locateNotice = '正在定位生成的素材…'; + if (!target) { + // 不在投影里:还没同步到画布,或者素材已经不在项目里。两种都当场给结论, + // 不放 intent 也不留中转提示。 + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + (context.manifest.assets ?? []).some((asset) => asset.id === assetId) + ? '素材已登记但尚未同步到画布,请稍候重试' + : '素材已不在项目里(可能已被删除)', + ); + return; + } // 手动定位不能复用自动落卡那条 commitId:`focusedCommitIdsRef` 会把同一个 commitId 记为 // 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。 const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`; activeFocusFlowIdRef.current = flowId; pendingResourceFocusRef.current = { flowId, - saveAttemptId: task.assetId, - sessionId: task.assetId, - draftId: task.assetId, + saveAttemptId: assetId, + sessionId: assetId, + draftId: assetId, commitId: flowId, projectPath: context.projectPath, projectId: context.projectId, focusGeneration: focusGenerationRef.current, - resourceId: `asset:${task.assetId}`, + resourceId, completed: false, }; - setResourceWorkbenchNotice('正在定位生成的素材…'); + if (target.category !== context.activePageCategory) { + // 素材在别的栏目:先切过去(切栏目本身就是 effect 的依赖变化),再让聚焦链在那边定位。 + selectResourceCanvasPage(target.category); + } + setResourceWorkbenchNotice(locateNotice); + setResourceAssetGenerationFocusRequest((current) => current + 1); + window.setTimeout(() => { + if (focusedCommitIdsRef.current.has(flowId)) { + // 真的聚焦过了。 + return; + } + const pending = pendingResourceFocusRef.current; + if (pending?.flowId === flowId) { + if (resourceWorkbenchNoticeRef.current !== locateNotice) { + // 聚焦链已经给出别的结论(例如「被当前搜索条件隐藏」+「清除搜索并定位」)。 + return; + } + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + '未能定位到素材:画布可能仍在布局或素材暂不可见,请稍后重试', + ); + return; + } + if (resourceWorkbenchNoticeRef.current === '') { + // intent 被判 invalid(项目 / 画布已切换)时聚焦链会清掉 intent 与提示:手动点击 + // 不能静默丢弃,给一条能解释「为什么没动」的结论。 + setResourceWorkbenchNotice( + '定位请求已失效(项目或画布已切换),请重新点击定位', + ); + } + }, 3_000); }, - [], + [selectResourceCanvasPage], ); /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ @@ -7397,14 +7558,17 @@ export default function ProjectDevelopmentView({ ) : null} {/* - 「生成任务」入口:生成在后台跑,面板关掉之后进度、阶段与失败原因都只在这 - 里可见(非模态浮层,画布照常可用)。 + 「生成任务」入口:常驻可见,开合画布上的任务侧栏;有在途任务时带上数量, + 用户关掉侧栏后一眼就能看出还有几条在跑。 */} {pendingResourceEdits.length > 0 || @@ -8439,35 +8603,50 @@ export default function ProjectDevelopmentView({ ) : null} {resourceAssetGenerationAction ? ( - task.taskId === resourceAssetGenerationPanelTaskIdRef.current, - )?.phaseDetail ?? null + draft={ + resourceAssetGenerationPanelReopen?.actionId === + resourceAssetGenerationAction.id + ? resourceAssetGenerationPanelReopen.draft + : undefined + } + error={ + resourceAssetGenerationPanelReopen?.actionId === + resourceAssetGenerationAction.id + ? resourceAssetGenerationPanelReopen.error + : null } onSubmit={(input) => - submitResourceAssetGeneration( - resourceAssetGenerationAction, - input, - ) + submitResourceAssetGeneration(resourceAssetGenerationAction, input) } - onClose={() => setResourceAssetGenerationAction(null)} + onClose={() => { + setResourceAssetGenerationPanelReopen(null); + setResourceAssetGenerationAction(null); + }} /> ) : null} {/* - 「生成任务」面板:非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡 - 判据——生成在后台跑,面板开着时画布必须照样能看能用。 + 「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` + 的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图, + 任务本身活在账本与本地队列里。 */} - {resourceAssetGenerationTasksPanelOpen ? ( - task.projectId === manifest.projectId, - )} - onClose={() => setResourceAssetGenerationTasksPanelOpen(false)} - onFocusTask={focusResourceAssetGenerationTask} - /> - ) : null} + task.projectId === manifest.projectId, + )} + open={resourceAssetGenerationTasksPanelOpen} + onToggleOpen={() => + setResourceAssetGenerationTasksPanelOpen((current) => !current) + } + onFocusTask={focusResourceAssetGenerationTask} + /> {resourcePanelOpen ? ( - expect( - screen.queryByRole('dialog', { name: '生成 UI 设计图' }), - ).toBeNull(), + within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }), ); + // 点击即关闭:同一个事件循环内提交面板就已卸载,画布立刻可用。 + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect(firstPanel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); // 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。 const taskPanel = await screen.findByRole('region', { name: '生成任务' }); @@ -10768,8 +10770,15 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), { target: { value: '第二条界面' }, }); - fireEvent.click(within(secondPanel).getByRole('button', { name: '生成 UI 设计图' })); - await within(secondPanel).findByText('排队中。'); + fireEvent.click( + within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }), + ); + // 第二条提交面板同样点击即关闭;它停在本地队列里(阶段由任务面板呈现), + // 生成提交 IPC 仍然只有一次。 + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + await waitFor(() => + expect(within(taskPanel).getByText('排队中。')).not.toBeNull(), + ); expect( calls.filter( (call) => call.command === 'start_local_project_asset_generation', @@ -10778,12 +10787,6 @@ export function registerProjectAgentStatusTests() { await waitFor(() => expect(within(taskPanel).getByText('第二条设计图')).not.toBeNull(), ); - fireEvent.keyDown(window, { key: 'Escape' }); - await waitFor(() => - expect( - screen.queryByRole('dialog', { name: '生成 UI 设计图' }), - ).toBeNull(), - ); // 4) 第一条终态后自动补发第二条,两条都收口为已完成;每条完成各走一次「配对读 + 落卡」。 const manifestReadsBefore = calls.filter( @@ -10799,7 +10802,12 @@ export function registerProjectAgentStatusTests() { { timeout: 15_000 }, ); await waitFor( - () => expect(within(taskPanel).getAllByText('已完成')).toHaveLength(2), + () => + expect( + within(screen.getByRole('region', { name: '已完成' })).getAllByRole( + 'listitem', + ), + ).toHaveLength(2), { timeout: 15_000 }, ); expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2); @@ -10900,6 +10908,482 @@ export function registerProjectAgentStatusTests() { ).not.toBeNull(); }, 20_000); + /** + * 「定位到素材」的公共夹具:项目里有一个 character 资产 + 一条已完成、指向它的生成任务。 + * + * `openCategory` 决定先停在哪个栏目:停在别的栏目就能覆盖「素材在另一个栏目」这条分支。 + */ + async function renderGenerationLocateView(input: { + projectId: string; + projectPath: string; + openCategory: string; + assetId: string; + ledgerRecords: Record[]; + }) { + const manifest = createGameCreationAppManifest( + input.projectId, + '生成任务定位测试', + ); + manifest.assets = [ + { + id: input.assetId, + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/locate-target.png', + source: { kind: 'canvas', resourceId: 'locate-target-resource' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: input.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'list_local_project_asset_generations') { + return input.ledgerRecords; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: input.projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory(input.openCategory); + fireEvent.click(screen.getByRole('button', { name: '生成任务' })); + return screen.findByRole('region', { name: '生成任务' }); + } + + function completedGenerationRecord(input: { + taskId: string; + assetId: string | null; + projectId: string; + }) { + return { + taskId: input.taskId, + projectId: input.projectId, + kind: 'character', + assetName: '定位目标素材', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: 2, + assetId: input.assetId, + error: null, + }; + } + + it('locates a generated asset that lives in another column instead of leaving the notice pending', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-other-column', + projectPath: '/tmp/workbench-locate-other-column', + // 停在 UI 交互栏目:目标素材在 character 栏目。 + openCategory: 'UI 交互', + assetId: 'locate-other-column-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-other-column', + assetId: 'locate-other-column-asset', + projectId: 'workbench-locate-other-column', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + // 悬而未决的中转提示必须消失,并且真的切到目标素材所在栏目。 + await waitFor(() => + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), + ); + await waitFor(() => + expect( + document.querySelector( + '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]', + ), + ).not.toBeNull(), + ); + }, 20_000); + + it('focuses a generated asset that is already in the current column', async () => { + // 这条是「点了没反应」的最小复现:不切栏目、不搜索,画布状态一个都不变, + // 只靠聚焦请求序号让 effect 重跑。 + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-same-column', + projectPath: '/tmp/workbench-locate-same-column', + openCategory: '角色与对象', + assetId: 'locate-same-column-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-same-column', + assetId: 'locate-same-column-asset', + projectId: 'workbench-locate-same-column', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + await waitFor(() => + expect( + document.querySelector( + '.game-resource-card-select[data-resource-id="asset:locate-same-column-asset"][aria-pressed="true"]', + ), + ).not.toBeNull(), + ); + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); + }, 20_000); + + it('surfaces the existing clear-search action when the generated asset is filtered out', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-hidden', + projectPath: '/tmp/workbench-locate-hidden', + openCategory: '角色与对象', + assetId: 'locate-hidden-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-hidden', + assetId: 'locate-hidden-asset', + projectId: 'workbench-locate-hidden', + }), + ], + }); + + // 用搜索条件把目标素材挡掉:筛选面板的关键词就是画布唯一的搜索入口。 + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); + fireEvent.change(screen.getByLabelText('查找素材'), { + target: { value: 'zzz-no-such-resource' }, + }); + fireEvent.keyDown(document, { key: 'Escape' }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + await waitFor(() => + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), + ); + expect( + screen.getByRole('button', { name: '清除搜索并定位' }), + ).not.toBeNull(); + }, 20_000); + + it('settles a locate request whose asset is not in the project at all', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-missing', + projectPath: '/tmp/workbench-locate-missing', + openCategory: '角色与对象', + assetId: 'locate-missing-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-missing', + assetId: 'asset-that-no-longer-exists', + projectId: 'workbench-locate-missing', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + expect( + await screen.findByText('素材已不在项目里(可能已被删除)'), + ).not.toBeNull(); + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); + }, 20_000); + + /** + * 提交面板的公共夹具:一个带权威规范图的 UI 交互栏目视图,`startLocalAsset` 决定 + * `start_local_project_asset_generation` 这一次调用的行为。 + */ + async function renderAssetGenerationSubmitView(input: { + projectId: string; + projectPath: string; + startLocalAsset: (args: { + taskId: string; + assetName: string; + }) => Promise; + listRecords?: (started: Map>) => unknown; + }) { + const manifest = createGameCreationAppManifest( + input.projectId, + '生成提交面板测试', + ); + manifest.assets = [ + { + id: 'submit-art-spec', + kind: 'icon-spec', + mediaType: 'image/png', + localPath: 'assets/art-spec.png', + source: { kind: 'canvas', resourceId: 'submit-art-spec-resource' }, + }, + ]; + const tasks = new Map>(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: input.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'start_local_project_asset_generation') { + const started = await input.startLocalAsset({ + taskId: String(args?.taskId), + assetName: String(args?.assetName), + }); + if (started) { + tasks.set(String(args?.taskId), started as Record); + } + return started; + } + if (command === 'list_local_project_asset_generations') { + if (input.listRecords) { + return input.listRecords(tasks); + } + return [...tasks.values()]; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: input.projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + fireEvent.click( + await screen.findByRole('button', { name: '生成 UI 设计图' }), + ); + const panel = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + fireEvent.change(within(panel).getByLabelText('素材名称'), { + target: { value: '待提交设计图' }, + }); + fireEvent.change(within(panel).getByLabelText('生成提示词'), { + target: { value: '主界面与背包页' }, + }); + return panel; + } + + it('closes the submission panel synchronously on submit and keeps stage text out of it', async () => { + // 提交这一步挂住:面板仍然必须立刻消失(不等受理、不等排队、不等生成)。 + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-sync-close', + projectPath: '/tmp/workbench-submit-sync-close', + startLocalAsset: () => new Promise(() => undefined), + }); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + // 阶段文案只出现在任务面板 / 提示条里,不在提交面板里。 + expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull(); + }, 20_000); + + it('brings the submission panel back with the draft when the backend never accepted the submit', async () => { + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-instant-failure', + projectPath: '/tmp/workbench-submit-instant-failure', + startLocalAsset: () => + Promise.reject( + new Error('项目权限策略拒绝执行:canvas.asset_generate'), + ), + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + + // 后端从未受理 → 面板连草稿一起带回来,错误可见、可直接改后重试。 + const reopened = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + await waitFor(() => + expect(within(reopened).getByRole('alert').textContent).toContain( + '项目权限策略拒绝执行:canvas.asset_generate', + ), + ); + expect( + (within(reopened).getByLabelText('生成提示词') as HTMLTextAreaElement) + .value, + ).toBe('主界面与背包页'); + expect( + (within(reopened).getByLabelText('素材名称') as HTMLInputElement).value, + ).toBe('待提交设计图'); + }, 20_000); + + it('does not reopen the submission panel when an accepted task fails later', async () => { + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-late-failure', + projectPath: '/tmp/workbench-submit-late-failure', + startLocalAsset: async ({ taskId }) => ({ + taskId, + projectId: 'workbench-submit-late-failure', + kind: 'ui-prototype', + assetName: '待提交设计图', + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: null, + assetId: null, + error: null, + }), + // 后端已经受理(start 返回了记录),随后这次生成失败。 + listRecords: (started) => + [...started.values()].map((task) => ({ + ...task, + status: 'failed', + phaseDetail: '生成失败:远端拒绝', + finishedAtMillis: 2, + error: '远端拒绝', + })), + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 受理之后才失败:面板不回来,只在任务面板收口为失败 + 一次提示条。 + await waitFor(() => + expect(screen.getByText('生成素材失败:远端拒绝')).not.toBeNull(), + ); + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect( + within(screen.getByRole('region', { name: '生成任务' })).getByText( + '生成失败:远端拒绝', + ), + ).not.toBeNull(); + }, 20_000); + + it('keeps a generation progressing while the sidebar is collapsed', async () => { + // 前两轮轮询先保持「在途」,让折叠后的在途计数可观测,之后才收口。 + let listPolls = 0; + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-sidebar-collapsed', + projectPath: '/tmp/workbench-sidebar-collapsed', + startLocalAsset: async ({ taskId }) => ({ + taskId, + projectId: 'workbench-sidebar-collapsed', + kind: 'ui-prototype', + assetName: '待提交设计图', + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: null, + assetId: null, + error: null, + }), + listRecords: (started) => { + listPolls += 1; + return [...started.values()].map((task) => + listPolls >= 3 + ? { + ...task, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'sidebar-collapsed-asset', + finishedAtMillis: 2, + } + : task, + ); + }, + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 提交后侧栏自动展开(对齐网页端:排队提交后主动弹任务栏)。 + const sidebar = await screen.findByRole('region', { name: '生成任务' }); + expect(within(sidebar).getByText('待提交设计图')).not.toBeNull(); + + // 折叠侧栏:折叠只影响这个视图,任务仍在后台推进。 + fireEvent.click( + within(sidebar).getByRole('button', { name: '收起生成任务' }), + ); + expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(); + const handle = screen.getByRole('button', { name: '展开生成任务' }); + expect(handle.dataset.resourceGenerationTaskCount).toBe('1'); + // 收口后折叠把手上的在途计数跟着归零 —— 折叠期间进度照常更新。 + await waitFor( + () => + expect( + screen.getByRole('button', { name: '展开生成任务' }).dataset + .resourceGenerationTaskCount, + ).toBe('0'), + { timeout: 15_000 }, + ); + + fireEvent.click(screen.getByRole('button', { name: '展开生成任务' })); + const reopened = await screen.findByRole('region', { name: '生成任务' }); + expect(within(reopened).getByText('生成已完成。')).not.toBeNull(); + }, 20_000); + it('routes the audio column entries to the existing audio generation chain', async () => { const manifest = createGameCreationAppManifest( 'workbench-bottom-toolbar-audio', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index 50b78e5a6..1b7c0a8d0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -1,5 +1,12 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -26,29 +33,15 @@ const uiPrototypeAction: ResourceCanvasAssetToolAction = { writesIconSpecReference: false, }; -/** 永不 resolve 的提交:模拟「生成还要跑 35 分钟」的在途状态。 */ +/** 永不 resolve 的提交:音频面板仍然等生成结束,用它模拟在途状态。 */ function pendingSubmit() { return new Promise(() => undefined); } -async function fillAndSubmitAssetPanel(onSubmit: () => Promise) { - const user = userEvent.setup(); - render( - undefined} - />, - ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - return user; -} - -describe('图片类生成面板在提交期间可关闭', () => { - test('提交在途时点 × 能关闭面板,且关闭不等于取消请求', async () => { +describe('图片类生成面板:点击即关闭,面板里不出现阶段文案', () => { + test('点击生成同步调用提交并关闭面板,面板 DOM 里从不出现阶段 / 排队文案', async () => { const onClose = vi.fn(); - const onSubmit = vi.fn(pendingSubmit); + const onSubmit = vi.fn(); const user = userEvent.setup(); render( { />, ); await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + // 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。 + const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); + expect( + ( + within(panel).getByRole('button', { + name: '生成 UI 设计图', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); - const closeButton = screen.getByRole('button', { - name: '关闭生成 UI 设计图', - }) as HTMLButtonElement; - expect(closeButton.disabled).toBe(false); - await user.click(closeButton); - expect(onClose).toHaveBeenCalledTimes(1); - // 关闭面板只是卸载 view:请求仍在飞,面板不该去「取消」它。 + await user.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 提交与关闭在同一个事件循环里发生:面板不等受理、不等排队、不等生成。 expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + expect(screen.queryByRole('button', { name: '后台运行并关闭' })).toBeNull(); }); - test('提交在途时 Esc 与点遮罩都能关面板', async () => { + test('× / Esc / 点遮罩仍能关面板,且关闭不等于取消', async () => { const onClose = vi.fn(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); render( , ); - const user = userEvent.setup(); await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - fireEvent.keyDown(window, { key: 'Escape' }); + await user.click( + screen.getByRole('button', { name: '关闭生成 UI 设计图' }), + ); expect(onClose).toHaveBeenCalledTimes(1); - + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(2); const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; fireEvent.pointerDown(backdrop); fireEvent.pointerUp(backdrop); fireEvent.click(backdrop); - expect(onClose).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenCalledTimes(3); + expect(onSubmit).not.toHaveBeenCalled(); }); - test('提交在途时提供「后台运行并关闭」,文案明说关闭不等于取消', async () => { + test('即时失败重开时带回草稿与失败原因,改完就能重试', async () => { const onClose = vi.fn(); - const onSubmit = vi.fn(pendingSubmit); + const onSubmit = vi.fn(); const user = userEvent.setup(); render( , ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - const backgroundButton = screen.getByRole('button', { - name: '后台运行并关闭', - }); - await user.click(backgroundButton); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - test('提交失败后草稿仍留在面板里可以直接重试', async () => { - const onSubmit = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('生成素材失败:远端拒绝')); - const user = await fillAndSubmitAssetPanel(onSubmit); - - expect((await screen.findByRole('alert')).textContent).toContain( + expect(screen.getByRole('alert').textContent).toContain( '生成素材失败:远端拒绝', ); - expect((screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value).toBe( - '主界面与背包页', - ); expect( - (screen.getByLabelText('素材名称') as HTMLInputElement).disabled, - ).toBe(false); + (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, + ).toBe('主界面与背包页'); + await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'ui-prototype', + prompt: '主界面与背包页', + assetName: 'AI 生成 UI 设计图', + }); }); }); @@ -179,9 +179,7 @@ describe('音频生成面板在提交期间可关闭', () => { await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音'); await user.click(screen.getByRole('button', { name: '生成音效' })); - await user.click( - screen.getByRole('button', { name: '后台运行并关闭' }), - ); + await user.click(screen.getByRole('button', { name: '后台运行并关闭' })); expect(onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 0410d26e9..908aa2669 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -10,9 +10,9 @@ import { import { applyLocalProjectAssetGenerationRecords, createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, nextResourceCanvasAssetGenerationDispatch, RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, - type LocalProjectAssetGenerationTaskRecord, resourceCanvasAssetGenerationElapsedLabel, resourceCanvasAssetGenerationKindLabel, type ResourceCanvasAssetGenerationTask, @@ -180,7 +180,9 @@ describe('生成任务模型', () => { test('已耗时文案按分秒呈现', () => { expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒'); - expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1 分 12 秒'); + expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe( + '1 分 12 秒', + ); expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒'); }); }); @@ -284,9 +286,7 @@ describe('本地排队驱动器', () => { assertedMidFlight = true; // 第一条还在途:第二条必须停在本地队列里。 expect(api.startCallCount()).toBe(1); - const second = api - .tasks() - .find((task) => task.taskId === 'task-b'); + const second = api.tasks().find((task) => task.taskId === 'task-b'); expect(second?.status).toBe('queued'); expect(second?.dispatched).toBe(false); expect(second?.phaseDetail).toBe( @@ -308,18 +308,16 @@ describe('本地排队驱动器', () => { expect(assertedMidFlight).toBe(true); // 第一条终态后自动补发第二条:生成提交 IPC 次数 = 2。 expect(harness.startCallCount()).toBe(2); - expect(harness.settlements.map((item) => [item.taskId, item.status])).toEqual( - [ - ['task-a', 'completed'], - ['task-b', 'completed'], - ], - ); expect( - harness.tasks().map((task) => [task.taskId, task.status]), + harness.settlements.map((item) => [item.taskId, item.status]), ).toEqual([ ['task-a', 'completed'], ['task-b', 'completed'], ]); + expect(harness.tasks().map((task) => [task.taskId, task.status])).toEqual([ + ['task-a', 'completed'], + ['task-b', 'completed'], + ]); }); test('第一条失败也会补发第二条,失败原因按后端记录留在任务上', async () => { @@ -468,7 +466,8 @@ describe('非预期 IPC 形状 / 失败下的健壮性', () => { if (command === 'list_local_project_asset_generations') { listCalls += 1; if ( - listCalls <= RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT + listCalls <= + RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT ) { throw new Error('项目权限策略拒绝执行:asset.read'); } diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 7ef84bc4b..8ca99210c 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -1,13 +1,16 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { createResourceCanvasAssetGenerationTask, type ResourceCanvasAssetGenerationTask, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { + RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, + ResourceCanvasAssetGenerationTasksPanelView, +} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; afterEach(() => { @@ -48,109 +51,178 @@ function task( }; } -describe('「生成任务」面板', () => { - test('是非模态面板:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => { - const { container } = render( - undefined} - onFocusTask={() => undefined} - />, - ); +function renderSidebar( + tasks: readonly ResourceCanvasAssetGenerationTask[], + options: { open?: boolean } = {}, +) { + const onToggleOpen = vi.fn(); + const onFocusTask = vi.fn(); + const view = render( + , + ); + return { onToggleOpen, onFocusTask, ...view }; +} + +describe('「生成任务」侧栏', () => { + test('展开时是非模态侧栏:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => { + const { container } = renderSidebar([task({ taskId: 't1' })]); expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull(); expect(container.querySelector('[aria-modal="true"]')).toBeNull(); expect(container.querySelector('.fixed.inset-0')).toBeNull(); }); - test('状态、阶段文案、已耗时与素材名逐条呈现,阶段文案来自后端记录', async () => { - render( - undefined} - onFocusTask={() => undefined} - />, - ); + test('两个分栏各带条数,状态 / 阶段 / 已耗时 / 素材名逐条呈现', () => { + renderSidebar([ + task({ taskId: 't1', assetName: '主界面设计图' }), + task({ + taskId: 't2', + assetName: '背包界面设计图', + dispatched: true, + status: 'running', + // 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。 + phaseDetail: '正在处理。', + startedAtMillis: 2_000, + }), + task({ + taskId: 't3', + assetName: '结算界面设计图', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-3', + finishedAtMillis: 13_000, + }), + task({ + taskId: 't4', + assetName: '设置界面设计图', + dispatched: true, + status: 'failed', + phaseDetail: '生成失败:远端拒绝', + error: '远端拒绝', + }), + ]); - expect(screen.getByText('主界面设计图')).not.toBeNull(); - expect(screen.getByText('排队中')).not.toBeNull(); - expect(screen.getByText('生成中')).not.toBeNull(); - expect(screen.getByText('已完成')).not.toBeNull(); - expect(screen.getByText('失败')).not.toBeNull(); - expect(screen.getByText('正在处理。')).not.toBeNull(); - expect(screen.getByText('生成已完成。')).not.toBeNull(); - // 每条任务的阶段只渲染后端给的那一句:面板里没有第二个来源,也没有硬编码的假阶段。 - const runningRow = screen.getByText('背包界面设计图').closest('li'); - expect(runningRow?.textContent).toContain('正在处理。'); - expect(runningRow?.textContent).not.toContain('生成中…'); - expect(screen.getByRole('alert').textContent).toBe('远端拒绝'); - expect(screen.getByText('生成任务').textContent).toBe('生成任务'); + const activeSection = screen.getByRole('region', { name: '排队与生成中' }); + expect(within(activeSection).getByText('2')).not.toBeNull(); + expect(within(activeSection).getByText('主界面设计图')).not.toBeNull(); + expect(within(activeSection).getByText('排队中')).not.toBeNull(); + expect(within(activeSection).getByText('正在处理。')).not.toBeNull(); + expect(within(activeSection).getByText('生成中')).not.toBeNull(); - const user = userEvent.setup(); - // 已耗时的秒表由前端计时,但只在还有未终态任务时走;这里断言它至少渲染了。 + const doneSection = screen.getByRole('region', { name: '已完成' }); + expect(within(doneSection).getByText('2')).not.toBeNull(); + expect(within(doneSection).getByText('生成已完成。')).not.toBeNull(); + expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝'); expect( screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length, ).toBeGreaterThan(0); - void user; + }); + + test('展开状态下收起与关闭都走同一个折叠开关', async () => { + const user = userEvent.setup(); + const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]); + await user.click(screen.getByRole('button', { name: '收起生成任务' })); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: '关闭生成任务' })); + expect(onToggleOpen).toHaveBeenCalledTimes(2); + }); + + test('侧栏高度有界、列表自己滚动,「已完成」条数封顶', () => { + const doneTasks = Array.from( + { length: RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT + 5 }, + (_, index) => + task({ + taskId: `done-${index}`, + assetName: `历史设计图 ${index}`, + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: `asset-${index}`, + finishedAtMillis: 2_000, + }), + ); + const { container } = renderSidebar(doneTasks); + + const aside = container.querySelector('aside') as HTMLElement; + // 有界高度:上下都给死了,不靠内容撑高。 + expect(aside.className).toContain('top-16'); + expect(aside.className).toContain('bottom-24'); + const scroller = container.querySelector( + '[data-resource-generation-task-scroll]', + ) as HTMLElement; + expect(scroller.className).toContain('overflow-y-auto'); + expect(scroller.className).toContain('min-h-0'); + + expect( + within(screen.getByRole('region', { name: '已完成' })).getAllByRole( + 'listitem', + ), + ).toHaveLength(RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT); + expect( + screen.getByText( + `仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 5 条较早记录`, + ), + ).not.toBeNull(); + }); + + test('折叠时只留贴边把手,把手上的在途计数跟着任务走(0 / 2)', async () => { + const user = userEvent.setup(); + const collapsed = renderSidebar([], { open: false }); + const handle = screen.getByRole('button', { name: '展开生成任务' }); + expect(handle.getAttribute('aria-expanded')).toBe('false'); + expect(handle.dataset.resourceGenerationTaskCount).toBe('0'); + expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(); + await user.click(handle); + expect(collapsed.onToggleOpen).toHaveBeenCalledTimes(1); + cleanup(); + + const running = task({ + taskId: 't-running', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }); + const queued = task({ taskId: 't-queued' }); + const { container } = renderSidebar([running, queued], { open: false }); + const withTwo = container.querySelector( + 'button[data-resource-generation-task-count]', + ) as HTMLElement; + expect(withTwo.dataset.resourceGenerationTaskCount).toBe('2'); + expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2'); }); test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => { - const onFocusTask = vi.fn(); const user = userEvent.setup(); - render( - undefined} - onFocusTask={onFocusTask} - />, - ); + const { onFocusTask } = renderSidebar([ + task({ + taskId: 't1', + assetName: '主界面设计图', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-1', + finishedAtMillis: 5_000, + }), + task({ + taskId: 't2', + assetName: '背包界面设计图', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + ]); - expect(screen.queryByRole('button', { name: '定位素材 背包界面设计图' })).toBeNull(); - await user.click(screen.getByRole('button', { name: '定位素材 主界面设计图' })); + expect( + screen.queryByRole('button', { name: '定位素材 背包界面设计图' }), + ).toBeNull(); + await user.click( + screen.getByRole('button', { name: '定位素材 主界面设计图' }), + ); expect(onFocusTask).toHaveBeenCalledTimes(1); expect(onFocusTask.mock.calls[0]?.[0]).toMatchObject({ taskId: 't1', @@ -158,18 +230,8 @@ describe('「生成任务」面板', () => { }); }); - test('没有任务时给出空状态,关闭按钮始终可用', async () => { - const onClose = vi.fn(); - const user = userEvent.setup(); - render( - undefined} - />, - ); + test('没有任务时给出空状态', () => { + renderSidebar([]); expect(screen.getByRole('status').textContent).toBe('还没有生成任务'); - await user.click(screen.getByRole('button', { name: '关闭生成任务' })); - expect(onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index da9986e59..c228db240 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -417,16 +417,15 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { ); }); - test('空提示词不提交,失败保留草稿并可原样重试', async () => { - const onSubmit = vi - .fn<(input: unknown) => Promise>() - .mockRejectedValueOnce(new Error('图片比例不受支持:4:3')) - .mockResolvedValueOnce(undefined); - render( + test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + const action = assetActionOf('character', '生成角色形象'); + const { unmount } = render( undefined} + onClose={onClose} />, ); @@ -436,16 +435,40 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { target: { value: '披风猫骑士' }, }); fireEvent.click(submit); - expect(await screen.findByRole('alert')).not.toBeNull(); + // 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + unmount(); + + // 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。 + const first = onSubmit.mock.calls[0]?.[0] as { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; + }; + render( + , + ); expect(screen.getByRole('alert').textContent).toContain( '图片比例不受支持:4:3', ); - // 失败不锁输入:同一份草稿再点一次就是同一个请求。 expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).disabled, - ).toBe(false); + (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, + ).toBe('披风猫骑士'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); }); });