Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch
This commit is contained in:
+55
-47
@@ -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>
|
||||
|
||||
+182
-75
@@ -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 (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl px-2 py-2"
|
||||
style={{ background: 'var(--platform-panel-fill)' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<strong className="text-xs font-bold">{task.assetName}</strong>
|
||||
<span className="text-[11px] opacity-70">{task.actionLabel}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
|
||||
<span className="rounded-full border border-black/10 px-2 py-0.5">
|
||||
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
<span>{task.phaseDetail}</span>
|
||||
<span className="opacity-70">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
|
||||
)}`}
|
||||
</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="mt-1 text-[11px]" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
{focusable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-[11px] underline"
|
||||
aria-label={`定位素材 ${task.assetName}`}
|
||||
onClick={() => onFocusTask(task)}
|
||||
>
|
||||
定位到素材
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「生成任务」浮层面板:列出每条图片类生成任务的状态、后端阶段文案、已耗时与素材名。
|
||||
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
|
||||
*
|
||||
* **它是非模态的**:不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` 的
|
||||
* 遮挡判据——生成在后台跑,面板开着的时候画布必须照样能看能用。这也是它和两块生成浮层
|
||||
* (提交表单,`ThemedModal` 模态)的关键差别。
|
||||
* 形态对齐网页端美术画布的任务侧栏:贴边的独立 `<aside>`,展开时是一列任务(画布照常可交互),
|
||||
* 折叠时只留一个可点的贴边把手(带在途数量角标)。它**不是**模态浮层:不铺全屏遮罩、不做焦点
|
||||
* 陷阱、不进 `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的
|
||||
* 遮挡判据;折叠也不影响任务推进——任务活在账本与本地队列里,与这个视图的生命周期无关。
|
||||
*
|
||||
* 阶段文案直接渲染后端账本给的 `phaseDetail`,面板不自己拼阶段、不做百分比进度。
|
||||
* 位置取舍:AGC 栏目画布的右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以
|
||||
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
|
||||
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280–340px 区间内。
|
||||
*
|
||||
* 两个分栏(「排队/生成中」与「已完成」)只做客户端分组,数据源仍是同一份任务列表;「已完成」
|
||||
* 条数封顶且列表自身滚动,侧栏高度有界。
|
||||
*/
|
||||
export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
tasks,
|
||||
onClose,
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusTask,
|
||||
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
|
||||
const [nowMillis, setNowMillis] = useState(() => Date.now());
|
||||
const hasLiveTask = tasks.some(
|
||||
const inFlightCount = tasks.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
).length;
|
||||
const hasLiveTask = inFlightCount > 0;
|
||||
const ordered = useMemo(
|
||||
() => sortResourceCanvasAssetGenerationTasks(tasks),
|
||||
[tasks],
|
||||
);
|
||||
const active = ordered.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const done = ordered.filter((task) =>
|
||||
resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const visibleDone = done.slice(
|
||||
0,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
);
|
||||
|
||||
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
|
||||
useEffect(() => {
|
||||
@@ -49,84 +118,122 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLiveTask]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-theme platform-theme--light fixed top-1/2 left-0 z-40 flex -translate-y-1/2 flex-col items-center gap-1 rounded-r-xl border border-l-0 border-black/10 px-1.5 py-3 shadow-lg"
|
||||
style={{
|
||||
background: 'var(--platform-subpanel-fill)',
|
||||
color: 'var(--platform-text-strong)',
|
||||
}}
|
||||
aria-label="展开生成任务"
|
||||
aria-expanded={false}
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
<span className="text-[11px] font-bold [writing-mode:vertical-rl]">
|
||||
生成任务
|
||||
</span>
|
||||
{inFlightCount > 0 ? (
|
||||
<span
|
||||
className="rounded-full border border-black/10 px-1 text-[11px] font-black"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight size={13} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="platform-theme platform-theme--light fixed inset-x-3 bottom-20 z-40 max-h-[60vh] overflow-auto rounded-2xl border border-black/10 p-3 shadow-lg sm:inset-x-auto sm:right-4 sm:w-96"
|
||||
<aside
|
||||
className="platform-theme platform-theme--light fixed top-16 bottom-24 left-3 z-40 flex w-[300px] max-w-[80vw] flex-col rounded-2xl border border-black/10 shadow-lg"
|
||||
style={{
|
||||
background: 'var(--platform-subpanel-fill)',
|
||||
color: 'var(--platform-text-strong)',
|
||||
}}
|
||||
role="region"
|
||||
aria-label="生成任务"
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-black">生成任务</h2>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-black/10 px-3 py-2">
|
||||
<h2 className="flex items-center gap-1 text-sm font-black">
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
生成任务
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded-full"
|
||||
aria-label="收起生成任务"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-3 py-2"
|
||||
data-resource-generation-task-scroll=""
|
||||
>
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-xs" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section aria-label="排队与生成中">
|
||||
<h3 className="flex items-center justify-between text-xs font-bold">
|
||||
<span>排队/生成中</span>
|
||||
<span>{active.length}</span>
|
||||
</h3>
|
||||
{active.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">没有进行中的任务</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col gap-2">
|
||||
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="已完成" className="mt-3">
|
||||
<h3 className="flex items-center justify-between text-xs font-bold">
|
||||
<span>已完成</span>
|
||||
<span>{done.length}</span>
|
||||
</h3>
|
||||
{done.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">还没有完成的任务</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-1 flex flex-col gap-2">
|
||||
{visibleDone.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
{done.length > visibleDone.length ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
|
||||
done.length - visibleDone.length
|
||||
} 条较早记录`}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex items-center justify-end border-t border-black/10 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded-full"
|
||||
aria-label="关闭生成任务"
|
||||
onClick={onClose}
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
{ordered.length === 0 ? (
|
||||
<p className="mt-2 text-xs" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{ordered.map((task) => {
|
||||
const focusable =
|
||||
task.status === 'completed' && Boolean(task.assetId);
|
||||
return (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl px-2 py-2"
|
||||
style={{ background: 'var(--platform-panel-fill)' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<strong className="text-xs font-bold">
|
||||
{task.assetName}
|
||||
</strong>
|
||||
<span className="text-[11px] opacity-70">
|
||||
{task.actionLabel}
|
||||
</span>
|
||||
</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]}
|
||||
</span>
|
||||
<span>{task.phaseDetail}</span>
|
||||
<span className="opacity-70">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(
|
||||
task,
|
||||
nowMillis,
|
||||
),
|
||||
)}`}
|
||||
</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="mt-1 text-[11px]" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
{focusable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-[11px] underline"
|
||||
aria-label={`定位素材 ${task.assetName}`}
|
||||
onClick={() => onFocusTask(task)}
|
||||
>
|
||||
定位到素材
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-7
@@ -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);
|
||||
|
||||
+2
-5
@@ -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,
|
||||
@@ -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<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);
|
||||
/**
|
||||
* 「定位到素材」的聚焦请求序号。
|
||||
*
|
||||
* 聚焦 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({
|
||||
</button>
|
||||
) : null}
|
||||
{/*
|
||||
「生成任务」入口:生成在后台跑,面板关掉之后进度、阶段与失败原因都只在这
|
||||
里可见(非模态浮层,画布照常可用)。
|
||||
「生成任务」入口:常驻可见,开合画布上的任务侧栏;有在途任务时带上数量,
|
||||
用户关掉侧栏后一眼就能看出还有几条在跑。
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-label="生成任务"
|
||||
aria-expanded={resourceAssetGenerationTasksPanelOpen}
|
||||
data-resource-generation-task-count={
|
||||
resourceAssetGenerationInFlightCount
|
||||
}
|
||||
onClick={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen(
|
||||
(current) => !current,
|
||||
@@ -7412,8 +7576,8 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
{resourceAssetGenerationTasks.length > 0
|
||||
? `生成任务 ${resourceAssetGenerationTasks.length}`
|
||||
{resourceAssetGenerationInFlightCount > 0
|
||||
? `生成任务 · ${resourceAssetGenerationInFlightCount}`
|
||||
: '生成任务'}
|
||||
</button>
|
||||
{pendingResourceEdits.length > 0 ||
|
||||
@@ -8439,35 +8603,50 @@ 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}
|
||||
{/*
|
||||
「生成任务」面板:非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡
|
||||
判据——生成在后台跑,面板开着时画布必须照样能看能用。
|
||||
「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen`
|
||||
的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,
|
||||
任务本身活在账本与本地队列里。
|
||||
*/}
|
||||
{resourceAssetGenerationTasksPanelOpen ? (
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={resourceAssetGenerationTasks.filter(
|
||||
(task) => task.projectId === manifest.projectId,
|
||||
)}
|
||||
onClose={() => setResourceAssetGenerationTasksPanelOpen(false)}
|
||||
onFocusTask={focusResourceAssetGenerationTask}
|
||||
/>
|
||||
) : null}
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={resourceAssetGenerationTasks.filter(
|
||||
(task) => task.projectId === manifest.projectId,
|
||||
)}
|
||||
open={resourceAssetGenerationTasksPanelOpen}
|
||||
onToggleOpen={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen((current) => !current)
|
||||
}
|
||||
onFocusTask={focusResourceAssetGenerationTask}
|
||||
/>
|
||||
|
||||
{resourcePanelOpen ? (
|
||||
<ResourceCanvasPanelView
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+61
-63
@@ -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');
|
||||
}
|
||||
|
||||
+164
-102
@@ -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(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[task({ taskId: 't1' })]}
|
||||
onClose={() => undefined}
|
||||
onFocusTask={() => undefined}
|
||||
/>,
|
||||
);
|
||||
function renderSidebar(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
options: { open?: boolean } = {},
|
||||
) {
|
||||
const onToggleOpen = vi.fn();
|
||||
const onFocusTask = vi.fn();
|
||||
const view = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={tasks}
|
||||
open={options.open ?? true}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onFocusTask={onFocusTask}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[
|
||||
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: '远端拒绝',
|
||||
}),
|
||||
]}
|
||||
onClose={() => 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(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[
|
||||
task({
|
||||
taskId: 't1',
|
||||
assetName: '主界面设计图',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-1',
|
||||
finishedAtMillis: 5_000,
|
||||
}),
|
||||
task({
|
||||
taskId: 't2',
|
||||
assetName: '背包界面设计图',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
}),
|
||||
]}
|
||||
onClose={() => 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(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
onClose={onClose}
|
||||
onFocusTask={() => undefined}
|
||||
/>,
|
||||
);
|
||||
test('没有任务时给出空状态', () => {
|
||||
renderSidebar([]);
|
||||
expect(screen.getByRole('status').textContent).toBe('还没有生成任务');
|
||||
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user