fix(agc): 生成面板点击即关闭、面板内不再出现阶段文案,只有即时失败才把草稿带回来

- ResourceCanvasAssetGenerationPanelView:删掉 `phaseDetail` 通道与整套在途状态(`submitting` /
  禁用输入 / 「后台运行并关闭」),主按钮文案恒为动作名;提交时**同步**调用 onSubmit 并 onClose,
  面板不等 IPC、不等排队、不等生成。新增 `draft` / `error` 两个入参:只有「后端从未受理」的即时
  失败重开时,宿主才把草稿与原因带回来(`role="alert"` 可见、改完即可重试)
- index.tsx:`submitResourceAssetGeneration` 改为同步返回(不再 await 终局),记录本次提交上下文
  (taskId / action / 草稿 / 是否本来就该立刻派发),提交后自动开任务面板并给一次提示条;
  收尾回调里区分两类失败:`record === null` 且本来就该立刻派发 → 重开提交面板并带回草稿;
  受理之后才失败 → 不重开面板,只在任务面板收口为失败 + 提示条
- index.tsx:删掉只为旧「在途阶段文案」服务的 `resourceAssetGenerationPanelTaskIdRef` 与成功后的
  自动收面板逻辑(面板此时已关闭)
- 测试:`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 的图片类用例改写为「点击即关闭 +
  面板 DOM 里从不出现 排队中。/正在生成。/提交中…」+「即时失败重开带回草稿与原因」;音频面板用例不变
- 测试:`resourceCanvasBottomToolbar.test.tsx` 的失败重试用例按新语义改写(点击即关闭、重开时显示原因)
- 测试:AppSurface 新增三条(同步关闭且面板无阶段文案、后端未受理时重开并保留草稿、受理后失败不重开
  只在任务面板收口),并把既有生成任务 e2e 的提交步骤改为「点击即关闭」
- 顺带按仓库规范修正以上文件的 eslint import sort 与 prettier 格式(未触碰无关文件)
This commit is contained in:
2026-09-14 18:35:11 +08:00
parent 2f47540eeb
commit 14a0286f70
10 changed files with 530 additions and 216 deletions
@@ -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<void>;
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<string | null>(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<string | null>(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<HTMLFormElement>) {
function submit(event: FormEvent<HTMLFormElement>) {
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({
<PlatformTextField
aria-label="素材名称"
maxLength={120}
disabled={submitting}
value={assetName}
onChange={(event) => 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}
/>
<PlatformSegmentedTabs
@@ -170,7 +180,6 @@ export function ResourceCanvasAssetGenerationPanelView({
columns="three"
gap="sm"
size="compact"
disabled={submitting}
onChange={setImageSize}
/>
</div>
@@ -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 ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{submitting ? (phaseDetail ?? '提交中…') : action.label}
{action.label}
</PlatformActionButton>
</div>
</form>
@@ -3,8 +3,8 @@ import { useEffect, useMemo, useState } from 'react';
import {
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationElapsedLabel,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
sortResourceCanvasAssetGenerationTasks,
@@ -95,7 +95,11 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span className="rounded-full border px-2 py-0.5">
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
{
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[
task.status
]
}
</span>
<span>{task.phaseDetail}</span>
<span className="opacity-70">
@@ -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);
@@ -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(
@@ -25,8 +25,8 @@ import {
Info,
Layers,
LayoutGrid,
ListFilter,
ListChecks,
ListFilter,
Maximize2,
Minus,
Music2,
@@ -110,9 +110,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,
@@ -329,18 +343,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 {
@@ -1573,10 +1575,32 @@ export default function ProjectDevelopmentView({
const resourceAssetGenerationTasksRef = useRef<
ResourceCanvasAssetGenerationTask[]
>([]);
/** 提交表单当前这次提交对应的任务 id:面板的在途文案要读该任务的后端阶段。 */
const resourceAssetGenerationPanelTaskIdRef = useRef<string | null>(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);
/**
*
*
@@ -1584,8 +1608,10 @@ export default function ProjectDevelopmentView({
* effect intent
*
*/
const [resourceAssetGenerationFocusRequest, setResourceAssetGenerationFocusRequest] =
useState(0);
const [
resourceAssetGenerationFocusRequest,
setResourceAssetGenerationFocusRequest,
] = useState(0);
/** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */
const resourceWorkbenchNoticeRef = useRef('');
const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] =
@@ -6716,11 +6742,15 @@ export default function ProjectDevelopmentView({
);
/**
* 宿 /
* 宿 / /
*
* manifest +
* `onManifestChange` `pendingResourceFocusRef`
*
*
* ****`record === null`
* 稿**** / /
*
*/
const handleResourceAssetGenerationSettlement = useCallback(
async (settlement: ResourceCanvasAssetGenerationSettlement) => {
@@ -6730,6 +6760,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 ?? '未知原因'}`,
@@ -6795,11 +6843,6 @@ export default function ProjectDevelopmentView({
completed: false,
};
setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…');
// 只在「这块表单还是这次提交的」时收面板:用户可能已经关掉它、又打开做第二次提交。
if (resourceAssetGenerationPanelTaskIdRef.current === settlement.taskId) {
resourceAssetGenerationPanelTaskIdRef.current = null;
setResourceAssetGenerationAction(null);
}
},
[],
);
@@ -6839,19 +6882,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,
@@ -6866,9 +6920,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);
},
[],
);
@@ -6891,7 +6961,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) {
@@ -8340,20 +8411,33 @@ export default function ProjectDevelopmentView({
) : null}
{resourceAssetGenerationAction ? (
<ResourceCanvasAssetGenerationPanelView
// 即时失败重开时用不同的 key,保证草稿与错误重新进初始状态。
key={`${resourceAssetGenerationAction.id}:${
resourceAssetGenerationPanelReopen?.actionId ===
resourceAssetGenerationAction.id
? 'reopened'
: 'fresh'
}`}
action={resourceAssetGenerationAction}
phaseDetail={
resourceAssetGenerationTasks.find(
(task) =>
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}
{/*
@@ -364,7 +364,9 @@ function generateCall(calls: readonly BottomToolbarInvokeCall[], kind: string) {
invokeStringField(entry.args, 'kind') === kind,
);
if (!call?.args) {
throw new Error(`missing start_local_project_asset_generation call for ${kind}`);
throw new Error(
`missing start_local_project_asset_generation call for ${kind}`,
);
}
return call.args;
}
@@ -10661,7 +10663,8 @@ export function registerProjectAgentStatusTests() {
}
if (command === 'start_local_project_asset_generation') {
const kind = invokeStringField(args, 'kind') ?? 'unknown';
const taskId = invokeStringField(args, 'taskId') ?? `task-${tasks.size}`;
const taskId =
invokeStringField(args, 'taskId') ?? `task-${tasks.size}`;
const task = {
taskId,
projectId: String(args?.projectId ?? manifest.projectId),
@@ -10726,7 +10729,9 @@ export function registerProjectAgentStatusTests() {
expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull();
// 1) 提交第一条;生成在途时面板能关(关闭 ≠ 取消请求)。
fireEvent.click(await screen.findByRole('button', { name: '生成 UI 设计图' }));
fireEvent.click(
await screen.findByRole('button', { name: '生成 UI 设计图' }),
);
const firstPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
@@ -10736,15 +10741,12 @@ export function registerProjectAgentStatusTests() {
fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), {
target: { value: '第一条界面' },
});
fireEvent.click(within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }));
fireEvent.click(
await screen.findByRole('button', { name: '后台运行并关闭' }),
);
await waitFor(() =>
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: '生成任务' });
@@ -10766,8 +10768,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',
@@ -10776,12 +10785,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(
@@ -11084,7 +11087,9 @@ export function registerProjectAgentStatusTests() {
await waitFor(() =>
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
);
expect(screen.getByRole('button', { name: '清除搜索并定位' })).not.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 () => {
@@ -11112,6 +11117,201 @@ export function registerProjectAgentStatusTests() {
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<unknown>;
listRecords?: (started: Map<string, Record<string, unknown>>) => 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<string, Record<string, unknown>>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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<string, unknown>);
}
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('routes the audio column entries to the existing audio generation chain', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-audio',
@@ -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<void>(() => undefined);
}
async function fillAndSubmitAssetPanel(onSubmit: () => Promise<void>) {
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
@@ -58,81 +51,88 @@ describe('图片类生成面板在提交期间可关闭', () => {
/>,
);
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(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={vi.fn(pendingSubmit)}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
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(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
draft={{
prompt: '主界面与背包页',
assetName: 'AI 生成 UI 设计图',
aspectRatio: '16:9',
imageSize: '1K',
}}
error="生成素材失败:远端拒绝"
onSubmit={onSubmit}
onClose={onClose}
/>,
);
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<void>>()
.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);
});
});
@@ -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');
}
@@ -3,11 +3,11 @@ import { cleanup, render, screen } 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 { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
afterEach(() => {
@@ -149,8 +149,12 @@ describe('「生成任务」面板', () => {
/>,
);
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',
@@ -417,16 +417,15 @@ describe('ResourceCanvasAssetGenerationPanelView', () => {
);
});
test('空提示词不提交,失败保留草稿并可原样重试', async () => {
const onSubmit = vi
.fn<(input: unknown) => Promise<void>>()
.mockRejectedValueOnce(new Error('图片比例不受支持:4:3'))
.mockResolvedValueOnce(undefined);
render(
test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => {
const onSubmit = vi.fn();
const onClose = vi.fn();
const action = assetActionOf('character', '生成角色形象');
const { unmount } = render(
<ResourceCanvasAssetGenerationPanelView
action={assetActionOf('character', '生成角色形象')}
action={action}
onSubmit={onSubmit}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={action}
draft={{
prompt: first.prompt,
assetName: first.assetName,
aspectRatio: first.aspectRatio,
imageSize: first.imageSize,
}}
error="图片比例不受支持:4:3"
onSubmit={onSubmit}
onClose={onClose}
/>,
);
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]);
});
});