fix: refine detail stitch preview and passthrough flow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-28 18:29:33 +08:00
parent ac58f28612
commit 81cd4d1754
2 changed files with 194 additions and 103 deletions

View File

@ -246,6 +246,20 @@ export function AgentDetailReferenceStitchDialog({
),
[cards]
);
const validCardLoadKey = useMemo(
() =>
validCards
.map((card) => `${card.id}::${String(card.imageUrl || "").trim()}`)
.join("|"),
[validCards]
);
const setupCardsRef = useRef(validCards);
const setupCardsKeyRef = useRef(validCardLoadKey);
if (setupCardsKeyRef.current !== validCardLoadKey) {
setupCardsKeyRef.current = validCardLoadKey;
setupCardsRef.current = validCards;
}
const setupCards = setupCardsRef.current;
const normalizedCutLines = useMemo(
() =>
@ -301,7 +315,7 @@ export function AgentDetailReferenceStitchDialog({
return;
}
if (validCards.length === 0) {
if (setupCards.length === 0) {
setSnapshot(null);
setCutLines([]);
setErrorText(`当前没有可拼合的${sourceLabel}`);
@ -317,11 +331,12 @@ export function AgentDetailReferenceStitchDialog({
setIsLoading(true);
setErrorText(null);
setWarningText(null);
setLoadingProgressText(`正在加载参考图 0/${setupCards.length}`);
const failedCards: string[] = [];
let processedCount = 0;
for (const [index, card] of validCards.entries()) {
setLoadingProgressText(`正在加载参考图 ${index + 1}/${validCards.length}`);
for (const [index, card] of setupCards.entries()) {
try {
const loaded = await loadImage(card.imageUrl);
loadedImages.push({
@ -340,6 +355,11 @@ export function AgentDetailReferenceStitchDialog({
imageUrl: card.imageUrl,
error,
});
} finally {
processedCount += 1;
if (!cancelled) {
setLoadingProgressText(`正在加载参考图 ${processedCount}/${setupCards.length}`);
}
}
}
@ -381,7 +401,7 @@ export function AgentDetailReferenceStitchDialog({
return () => {
cancelled = true;
};
}, [open, sourceLabel, validCards]);
}, [open, setupCards, sourceLabel]);
useEffect(() => {
const validIndexes = new Set(sliceSegments.map((segment) => segment.index));
@ -640,6 +660,15 @@ export function AgentDetailReferenceStitchDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="h-[92vh] max-w-[1440px] overflow-hidden border-white/10 bg-[#060809] p-0 text-white"
onEscapeKeyDown={(event) => {
event.preventDefault();
}}
onInteractOutside={(event) => {
event.preventDefault();
}}
onPointerDownOutside={(event) => {
event.preventDefault();
}}
overlayClassName="bg-black/85 backdrop-blur-sm"
>
<DialogHeader className="border-b border-white/10 px-6 py-5">
@ -731,70 +760,72 @@ export function AgentDetailReferenceStitchDialog({
{warningText}
</div>
) : null}
<div
ref={previewContainerRef}
className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white"
onPointerMove={handlePreviewPointerMove}
onPointerLeave={handlePreviewPointerLeave}
onClick={handlePreviewClick}
>
<canvas
ref={previewCanvasRef}
className="block h-auto w-full select-none"
/>
<div className="flex justify-center">
<div
ref={previewContainerRef}
className="relative w-full max-w-[650px] overflow-hidden rounded-[24px] border border-white/10 bg-white shadow-[0_18px_48px_rgba(0,0,0,0.22)]"
onPointerMove={handlePreviewPointerMove}
onPointerLeave={handlePreviewPointerLeave}
onClick={handlePreviewClick}
>
<canvas
ref={previewCanvasRef}
className="block h-auto w-full select-none"
/>
<div className="pointer-events-none absolute inset-0">
{isAddingCutLine && previewCutLineY !== null ? (
<div
className="absolute inset-x-0"
style={{ top: `${(previewCutLineY / snapshot.height) * 100}%` }}
>
<div className="absolute inset-x-0 flex -translate-y-1/2 items-center justify-center">
<div className="relative w-full">
<div className="h-[2px] w-full border-t border-dashed border-emerald-400 bg-transparent" />
<div className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-emerald-300/35 bg-black/72 px-2 py-1 text-[11px] text-emerald-100">
点击落线
</div>
</div>
</div>
</div>
) : null}
{normalizedCutLines.map((lineY, index) => {
const topPercent = (lineY / snapshot.height) * 100;
return (
<div className="pointer-events-none absolute inset-0">
{isAddingCutLine && previewCutLineY !== null ? (
<div
key={`cut-line-${index}-${lineY}`}
className="absolute inset-x-0"
style={{ top: `${topPercent}%` }}
style={{ top: `${(previewCutLineY / snapshot.height) * 100}%` }}
>
<div
onPointerDown={handleLinePointerDown(index)}
className={cn(
"pointer-events-auto absolute inset-x-0 flex -translate-y-1/2 cursor-row-resize items-center justify-center",
draggingLineIndex === index && "z-20"
)}
style={{ touchAction: "none" }}
>
<div className="absolute inset-x-0 flex -translate-y-1/2 items-center justify-center">
<div className="relative w-full">
<div className="h-[2px] w-full bg-emerald-400 shadow-[0_0_16px_rgba(52,211,153,0.42)]" />
<div className="h-[2px] w-full border-t border-dashed border-emerald-400 bg-transparent" />
<div className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-emerald-300/35 bg-black/72 px-2 py-1 text-[11px] text-emerald-100">
切线 {index + 1}
点击落线
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveCutLine(index);
}}
className="absolute right-3 top-1/2 pointer-events-auto -translate-y-1/2 rounded-full border border-white/12 bg-black/72 px-2 py-1 text-[11px] text-white/78 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
</div>
</div>
</div>
);
})}
) : null}
{normalizedCutLines.map((lineY, index) => {
const topPercent = (lineY / snapshot.height) * 100;
return (
<div
key={`cut-line-${index}-${lineY}`}
className="absolute inset-x-0"
style={{ top: `${topPercent}%` }}
>
<div
onPointerDown={handleLinePointerDown(index)}
className={cn(
"pointer-events-auto absolute inset-x-0 flex -translate-y-1/2 cursor-row-resize items-center justify-center",
draggingLineIndex === index && "z-20"
)}
style={{ touchAction: "none" }}
>
<div className="relative w-full">
<div className="h-[2px] w-full bg-emerald-400 shadow-[0_0_16px_rgba(52,211,153,0.42)]" />
<div className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-emerald-300/35 bg-black/72 px-2 py-1 text-[11px] text-emerald-100">
切线 {index + 1}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveCutLine(index);
}}
className="absolute right-3 top-1/2 pointer-events-auto -translate-y-1/2 rounded-full border border-white/12 bg-black/72 px-2 py-1 text-[11px] text-white/78 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
@ -810,7 +841,7 @@ export function AgentDetailReferenceStitchDialog({
<div className="border-b border-white/10 px-5 py-4">
<div className="text-sm font-semibold text-white">切割结果预估</div>
<div className="mt-1 text-xs text-white/42">
应用切割后,下方每一段都会回到详情替换参考图区;标记为“直接应用”的切片会跳过继续生成,未标记的切片再逐屏生成。
应用切割后,下方每一段都会回到详情替换参考图区;标记为“不处理直出”的切片会跳过继续生成,未标记的切片再逐屏生成。
</div>
</div>
@ -835,7 +866,7 @@ export function AgentDetailReferenceStitchDialog({
<div className="mt-3 flex items-center justify-between gap-2">
<div className="text-[11px] text-white/45">
{passthroughSegmentIndexSet.has(segment.index)
? "当前切片会直接进入结果区,不继续生成。"
? "当前切片不会参与生图,应用后会直接进入结果区。"
: "当前切片会继续参与详情替换生成。"}
</div>
<button
@ -855,8 +886,8 @@ export function AgentDetailReferenceStitchDialog({
)}
>
{passthroughSegmentIndexSet.has(segment.index)
? "取消直出"
: "标记直接应用"}
? "取消不处理"
: "标记不处理"}
</button>
</div>
</div>

View File

@ -779,6 +779,9 @@ type ReplacementCardStatus =
| "fallback"
| "missing";
const MANUAL_PASSTHROUGH_RESULT_TEXT =
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
const normalized = normalizeAssistantText(String(text || ""))
.replace(/\s+/g, " ")
@ -825,7 +828,7 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
switch (status) {
case "passthrough":
return {
label: "沿用参考图",
label: "不处理直出",
className: "border-cyan-400/30 bg-cyan-500/[0.10] text-cyan-200",
};
case "fallback":
@ -942,35 +945,53 @@ function AnimatedGenerationPlaceholderCard(params: {
badge: string;
badgeClassName?: string;
showSpinner?: boolean;
animated?: boolean;
}) {
const isAnimated = Boolean(params.animated);
return (
<div className="relative h-full w-full overflow-hidden bg-[linear-gradient(180deg,#060809_0%,#101417_100%)]">
<motion.div
aria-hidden
className="absolute inset-[-42%] origin-center"
animate={{ rotate: 360 }}
transition={{ duration: 5.2, repeat: Infinity, ease: "linear" }}
>
<div className="h-full w-full bg-[conic-gradient(from_0deg,rgba(16,185,129,0)_0deg,rgba(16,185,129,0.96)_42deg,rgba(34,211,238,0.86)_120deg,rgba(251,191,36,0.72)_198deg,rgba(168,85,247,0.88)_272deg,rgba(16,185,129,0)_360deg)] opacity-95" />
</motion.div>
<div className="absolute inset-[1.5px] rounded-[20px] bg-[linear-gradient(180deg,#080a0c_0%,#12171a_100%)]" />
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_58%)]"
animate={{ opacity: [0.42, 0.72, 0.42] }}
transition={{ duration: 2.3, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] opacity-70"
style={{
backgroundImage:
"linear-gradient(118deg, transparent 8%, rgba(255,255,255,0.08) 36%, transparent 62%)",
backgroundSize: "220% 100%",
}}
animate={{ backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"] }}
transition={{ duration: 3.6, repeat: Infinity, ease: "linear" }}
/>
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-[20px]",
isAnimated
? "bg-[linear-gradient(180deg,#060809_0%,#101417_100%)]"
: "bg-[linear-gradient(180deg,#090c0e_0%,#12171a_100%)]"
)}
>
{isAnimated ? (
<>
<motion.div
aria-hidden
className="absolute inset-[-42%] origin-center"
animate={{ rotate: 360 }}
transition={{ duration: 5.2, repeat: Infinity, ease: "linear" }}
>
<div className="h-full w-full bg-[conic-gradient(from_0deg,rgba(16,185,129,0)_0deg,rgba(16,185,129,0.96)_42deg,rgba(34,211,238,0.86)_120deg,rgba(251,191,36,0.72)_198deg,rgba(168,85,247,0.88)_272deg,rgba(16,185,129,0)_360deg)] opacity-95" />
</motion.div>
<div className="absolute inset-[1.5px] rounded-[20px] bg-[linear-gradient(180deg,#080a0c_0%,#12171a_100%)]" />
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_58%)]"
animate={{ opacity: [0.42, 0.72, 0.42] }}
transition={{ duration: 2.3, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] opacity-70"
style={{
backgroundImage:
"linear-gradient(118deg, transparent 8%, rgba(255,255,255,0.08) 36%, transparent 62%)",
backgroundSize: "220% 100%",
}}
animate={{ backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"] }}
transition={{ duration: 3.6, repeat: Infinity, ease: "linear" }}
/>
</>
) : (
<>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),transparent_58%)]" />
<div className="absolute inset-x-0 bottom-0 h-20 bg-[linear-gradient(180deg,rgba(255,255,255,0)_0%,rgba(255,255,255,0.03)_100%)]" />
</>
)}
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-3 px-5 text-center">
<div className="flex items-center gap-2">
@ -2408,20 +2429,34 @@ export function AgentGenerationWorkspaceDarkV2({
() =>
detailReplacementReferenceCards.map((card, index) => {
const resultMeta = latestReplacementResults[index];
const localPassthroughResultUrl =
!resultMeta?.url && card.passthrough && card.imageUrl
? card.imageUrl
: null;
const resultUrl =
resultMeta?.url &&
!normalizedDeletedResultCollections.detail_replacement.includes(resultMeta.url)
? resultMeta.url
: null;
: localPassthroughResultUrl &&
!normalizedDeletedResultCollections.detail_replacement.includes(
localPassthroughResultUrl
)
? localPassthroughResultUrl
: null;
const resultText = resultMeta?.url
? resultMeta?.text || ""
: localPassthroughResultUrl
? MANUAL_PASSTHROUGH_RESULT_TEXT
: "";
return {
...card,
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
resultText,
status: getReplacementCardStatus({
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
resultText,
generatedCount: latestReplacementResults.length,
isGenerating: isDetailResultModeGenerating,
}),
@ -2438,20 +2473,34 @@ export function AgentGenerationWorkspaceDarkV2({
() =>
skuReplacementReferenceCards.map((card, index) => {
const resultMeta = latestReplacementResults[index];
const localPassthroughResultUrl =
!resultMeta?.url && card.passthrough && card.imageUrl
? card.imageUrl
: null;
const resultUrl =
resultMeta?.url &&
!normalizedDeletedResultCollections.sku_replacement.includes(resultMeta.url)
? resultMeta.url
: null;
: localPassthroughResultUrl &&
!normalizedDeletedResultCollections.sku_replacement.includes(
localPassthroughResultUrl
)
? localPassthroughResultUrl
: null;
const resultText = resultMeta?.url
? resultMeta?.text || ""
: localPassthroughResultUrl
? MANUAL_PASSTHROUGH_RESULT_TEXT
: "";
return {
...card,
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
resultText,
status: getReplacementCardStatus({
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
resultText,
generatedCount: latestReplacementResults.length,
isGenerating: isSkuResultModeGenerating,
}),
@ -5639,7 +5688,7 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
{card.passthrough ? (
<div className="absolute right-3 top-12 rounded-full border border-cyan-300/35 bg-cyan-500/[0.14] px-2.5 py-1 text-[11px] text-cyan-100">
直出结果
不处理直出
</div>
) : null}
{isActive ? (
@ -5692,14 +5741,17 @@ export function AgentGenerationWorkspaceDarkV2({
(() => {
const isActive = activeDetailReferenceCardId === card.id;
const statusMeta = getReplacementCardStatusMeta(card.status);
const hasResult = Boolean(card.resultUrl);
return (
<div
key={`${card.id}-result`}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
hasResult && isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
: isActive
? "border-white/18 bg-white/[0.03] shadow-[0_0_0_1px_rgba(255,255,255,0.04)]"
: "border-white/10"
)}
>
{card.resultUrl ? (
@ -5764,6 +5816,7 @@ export function AgentGenerationWorkspaceDarkV2({
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”生成`}
@ -6172,14 +6225,17 @@ export function AgentGenerationWorkspaceDarkV2({
{skuResultCards.length > 0 ? (
skuResultCards.map((card) => {
const statusMeta = getReplacementCardStatusMeta(card.status);
const hasResult = Boolean(card.resultUrl);
return (
<div
key={`${card.id}-result`}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
hasResult && activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
: activeSkuReferenceCardId === card.id
? "border-white/18 bg-white/[0.03] shadow-[0_0_0_1px_rgba(255,255,255,0.04)]"
: "border-white/10"
)}
>
{card.resultUrl ? (
@ -6239,6 +6295,7 @@ export function AgentGenerationWorkspaceDarkV2({
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图 + 第 ${card.index + 1} 张${skuReferenceSourceLabel}参考图”生成`}
@ -6314,9 +6371,11 @@ export function AgentGenerationWorkspaceDarkV2({
className={cn(
"overflow-hidden rounded-[22px] border transition-all",
card.url ? "cursor-pointer" : "cursor-default",
isActive
card.url && isActive
? "border-emerald-400/55 bg-emerald-500/[0.08] ring-1 ring-emerald-400/35"
: "border-white/10 bg-black/20 hover:border-white/18"
: !card.url && isActive
? "border-white/18 bg-white/[0.03] shadow-[0_0_0_1px_rgba(255,255,255,0.04)]"
: "border-white/10 bg-black/20 hover:border-white/18"
)}
>
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
@ -6432,6 +6491,7 @@ export function AgentGenerationWorkspaceDarkV2({
? "border-white/12 bg-white/[0.05] text-white/62"
: "border-white/10 bg-white/[0.04] text-white/58"
}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
description={