feat: support deleting agent result cards
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-03-24 17:48:02 +08:00
parent 0a33d99c28
commit 83102c559f
4 changed files with 222 additions and 27 deletions

View File

@ -334,6 +334,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [agentProjectThumbnail, setAgentProjectThumbnail] = useState<string | null>(null);
const [agentProjectResultCollections, setAgentProjectResultCollections] =
useState<AgentGenerationProjectResultCollections>({});
const [agentProjectDeletedResultCollections, setAgentProjectDeletedResultCollections] =
useState<AgentGenerationProjectResultCollections>({});
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false);
const [isAgentProjectSheetOpen, setIsAgentProjectSheetOpen] = useState(false);
@ -509,6 +511,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
setAgentProjectTitle("未命名生图项目");
setAgentProjectThumbnail(null);
setAgentProjectResultCollections({});
setAgentProjectDeletedResultCollections({});
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
@ -658,6 +661,17 @@ export default function CanvasEditorPage({ params }: PageProps) {
agentProjectResultCollections.sku_replacement
),
},
deletedResultCollections: {
scene_generation: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.scene_generation
),
detail_replacement: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.detail_replacement
),
sku_replacement: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.sku_replacement
),
},
resultImageUrls: Array.from(
new Set([
...normalizeResultCollectionUrls(agentProjectResultCollections.scene_generation),
@ -668,6 +682,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
};
}, [
agentModeState,
agentProjectDeletedResultCollections,
agentProjectResultCollections,
agentWorkspaceState,
currentAgentStage,
@ -885,6 +900,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
setAgentProjectTitle(result.data.title || "未命名生图项目");
setAgentProjectThumbnail(result.data.thumbnail || null);
setAgentProjectResultCollections(persistedState.resultCollections || {});
setAgentProjectDeletedResultCollections(
persistedState.deletedResultCollections || {}
);
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载生图项目失败");
@ -1117,6 +1135,48 @@ export default function CanvasEditorPage({ params }: PageProps) {
[agentProjectResultCollections]
);
const handleAgentResultImageDelete = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
return;
}
const nextCollections: AgentGenerationProjectResultCollections = {
...agentProjectResultCollections,
[modeType]: normalizeResultCollectionUrls(
(agentProjectResultCollections[modeType] || []).filter(
(url) => url !== normalizedUrl
)
),
};
const nextDeletedCollections: AgentGenerationProjectResultCollections = {
...agentProjectDeletedResultCollections,
[modeType]: normalizeResultCollectionUrls([
...(agentProjectDeletedResultCollections[modeType] || []),
normalizedUrl,
]),
};
setAgentProjectResultCollections((current) =>
areResultCollectionsEqual(current, nextCollections) ? current : nextCollections
);
setAgentProjectDeletedResultCollections((current) =>
areResultCollectionsEqual(current, nextDeletedCollections)
? current
: nextDeletedCollections
);
setAgentProjectThumbnail((current) => {
const nextThumbnail =
current === normalizedUrl
? getFirstResultUrlFromCollections(nextCollections)
: current;
return current === nextThumbnail ? current : nextThumbnail;
});
},
[agentProjectDeletedResultCollections, agentProjectResultCollections]
);
useEffect(() => {
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
if (!normalizedBrand) {
@ -1795,8 +1855,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
onAgentModeStateChange={setAgentModeState}
workspaceState={agentWorkspaceState}
resultCollections={agentProjectResultCollections}
deletedResultCollections={agentProjectDeletedResultCollections}
onWorkspaceStateChange={setAgentWorkspaceState}
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
onDeleteResultImage={handleAgentResultImageDelete}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}

View File

@ -16,6 +16,7 @@ import {
Sparkles,
Upload,
UserRound,
Trash2,
X,
Wand2,
Settings2,
@ -93,10 +94,15 @@ interface AgentGenerationWorkspaceDarkV2Props {
workspaceState?: AgentGenerationProjectWorkspaceState | null;
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
resultCollections?: AgentGenerationProjectResultCollections | null;
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
onResultPreviewUrlsChange?: (
modeType: AgentGenerationResultModeType,
urls: string[]
) => void;
onDeleteResultImage?: (
modeType: AgentGenerationResultModeType,
imageUrl: string
) => void;
topNavRightSlot?: ReactNode;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
workflowStylePanel?: ReactNode;
@ -145,6 +151,14 @@ function normalizeResultCollectionUrls(urls?: string[] | null) {
);
}
function excludeDeletedResultUrls(
urls: string[] | undefined | null,
deletedUrls: string[] | undefined | null
) {
const deletedUrlSet = new Set(normalizeResultCollectionUrls(deletedUrls));
return normalizeResultCollectionUrls(urls).filter((url) => !deletedUrlSet.has(url));
}
function getResultModeLabel(modeType: AgentGenerationResultModeType) {
return modeType === "detail_replacement"
? "详情替换"
@ -677,8 +691,10 @@ export function AgentGenerationWorkspaceDarkV2({
onOpenCanvasEditor,
workspaceState = null,
resultCollections = null,
deletedResultCollections = null,
onWorkspaceStateChange,
onResultPreviewUrlsChange,
onDeleteResultImage,
topNavRightSlot,
showcaseConfig,
workflowStylePanel,
@ -759,6 +775,20 @@ export function AgentGenerationWorkspaceDarkV2({
});
const [pendingGeneration, setPendingGeneration] =
useState<LocalGenerationPendingState | null>(null);
const normalizedDeletedResultCollections = useMemo(
() => ({
scene_generation: normalizeResultCollectionUrls(
deletedResultCollections?.scene_generation
),
detail_replacement: normalizeResultCollectionUrls(
deletedResultCollections?.detail_replacement
),
sku_replacement: normalizeResultCollectionUrls(
deletedResultCollections?.sku_replacement
),
}),
[deletedResultCollections]
);
useEffect(() => {
const restored = getStoredAgentPendingGenerations().find(
@ -1310,8 +1340,20 @@ export function AgentGenerationWorkspaceDarkV2({
}, [assistantMessagesAfterLastUser, generatedGroups]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
() =>
Array.from(
new Set(
generatedGroups
.flatMap((group) => group.imageUrls)
.filter(
(url) =>
!normalizedDeletedResultCollections.scene_generation.includes(url) &&
!normalizedDeletedResultCollections.detail_replacement.includes(url) &&
!normalizedDeletedResultCollections.sku_replacement.includes(url)
)
)
),
[generatedGroups, normalizedDeletedResultCollections]
);
const currentSceneGeneratedUrls = useMemo(
() => Array.from(new Set(generatedGroupsAfterLastUser.flatMap((group) => group.imageUrls))),
@ -1357,8 +1399,11 @@ export function AgentGenerationWorkspaceDarkV2({
() =>
latestReplacementResults
.map((item) => item.url)
.filter((url): url is string => Boolean(url)),
[latestReplacementResults]
.filter((url): url is string => Boolean(url))
.filter(
(url) => !normalizedDeletedResultCollections[currentResultModeType].includes(url)
),
[currentResultModeType, latestReplacementResults, normalizedDeletedResultCollections]
);
const currentLiveResultUrls = isReplacementPreset
? latestReplacementResultUrls
@ -1400,9 +1445,18 @@ export function AgentGenerationWorkspaceDarkV2({
const mergedResultCollections = useMemo(
() => {
const baseCollections = {
scene_generation: normalizeResultCollectionUrls(resultCollections?.scene_generation),
detail_replacement: normalizeResultCollectionUrls(resultCollections?.detail_replacement),
sku_replacement: normalizeResultCollectionUrls(resultCollections?.sku_replacement),
scene_generation: excludeDeletedResultUrls(
resultCollections?.scene_generation,
normalizedDeletedResultCollections.scene_generation
),
detail_replacement: excludeDeletedResultUrls(
resultCollections?.detail_replacement,
normalizedDeletedResultCollections.detail_replacement
),
sku_replacement: excludeDeletedResultUrls(
resultCollections?.sku_replacement,
normalizedDeletedResultCollections.sku_replacement
),
};
return {
@ -1410,12 +1464,20 @@ export function AgentGenerationWorkspaceDarkV2({
[currentResultModeType]: Array.from(
new Set([
...baseCollections[currentResultModeType],
...normalizeResultCollectionUrls(currentLiveResultUrls),
...excludeDeletedResultUrls(
currentLiveResultUrls,
normalizedDeletedResultCollections[currentResultModeType]
),
])
),
} as Record<AgentGenerationResultModeType, string[]>;
},
[currentLiveResultUrls, currentResultModeType, resultCollections]
[
currentLiveResultUrls,
currentResultModeType,
normalizedDeletedResultCollections,
resultCollections,
]
);
const availableResultModeTypes = useMemo(
() =>
@ -1434,43 +1496,61 @@ export function AgentGenerationWorkspaceDarkV2({
() =>
detailReplacementReferenceCards.map((card, index) => {
const resultMeta = latestReplacementResults[index];
const resultUrl = resultMeta?.url || null;
const resultUrl =
resultMeta?.url &&
!normalizedDeletedResultCollections.detail_replacement.includes(resultMeta.url)
? resultMeta.url
: null;
return {
...card,
index,
resultUrl,
resultText: resultMeta?.text || "",
resultText: resultUrl ? resultMeta?.text || "" : "",
status: getReplacementCardStatus({
index,
resultUrl,
resultText: resultMeta?.text || "",
resultText: resultUrl ? resultMeta?.text || "" : "",
generatedCount: latestReplacementResults.length,
isGenerating: isDetailResultModeGenerating,
}),
};
}),
[detailReplacementReferenceCards, isDetailResultModeGenerating, latestReplacementResults]
[
detailReplacementReferenceCards,
isDetailResultModeGenerating,
latestReplacementResults,
normalizedDeletedResultCollections.detail_replacement,
]
);
const skuResultCards = useMemo(
() =>
skuReplacementReferenceCards.map((card, index) => {
const resultMeta = latestReplacementResults[index];
const resultUrl = resultMeta?.url || null;
const resultUrl =
resultMeta?.url &&
!normalizedDeletedResultCollections.sku_replacement.includes(resultMeta.url)
? resultMeta.url
: null;
return {
...card,
index,
resultUrl,
resultText: resultMeta?.text || "",
resultText: resultUrl ? resultMeta?.text || "" : "",
status: getReplacementCardStatus({
index,
resultUrl,
resultText: resultMeta?.text || "",
resultText: resultUrl ? resultMeta?.text || "" : "",
generatedCount: latestReplacementResults.length,
isGenerating: isSkuResultModeGenerating,
}),
};
}),
[isSkuResultModeGenerating, latestReplacementResults, skuReplacementReferenceCards]
[
isSkuResultModeGenerating,
latestReplacementResults,
normalizedDeletedResultCollections.sku_replacement,
skuReplacementReferenceCards,
]
);
const detailProcessedCount = useMemo(
() =>
@ -1719,10 +1799,7 @@ export function AgentGenerationWorkspaceDarkV2({
return;
}
const hasProgress =
pendingGeneration.modeType === "scene_generation"
? currentSceneGeneratedUrls.length > 0
: latestReplacementResults.length > 0;
const hasProgress = latestReplacementResults.length > 0;
if (!hasProgress) {
setPendingGeneration(null);
@ -2090,6 +2167,25 @@ export function AgentGenerationWorkspaceDarkV2({
toast.message("已打开原图,可右键另存为");
}
}, []);
const handleDeleteResultImage = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onDeleteResultImage) {
return;
}
if (selectedResultUrl === normalizedUrl) {
setSelectedResultUrl(null);
}
if (fullscreenPreview === normalizedUrl) {
setFullscreenPreview(null);
}
onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除");
},
[fullscreenPreview, onDeleteResultImage, selectedResultUrl]
);
const handleToolStepChange = useCallback(
(nextKey: ToolStepKey) => {
@ -3370,6 +3466,16 @@ export function AgentGenerationWorkspaceDarkV2({
当前联动
</div>
) : null}
<button
type="button"
onClick={() =>
handleDeleteResultImage("detail_replacement", card.resultUrl!)
}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full border border-rose-400/25 bg-black/60 text-rose-100 transition-colors hover:border-rose-300/45 hover:bg-rose-500/15"
aria-label="删除结果图"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
@ -3815,6 +3921,16 @@ export function AgentGenerationWorkspaceDarkV2({
<div className={cn("absolute left-3 top-3 rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<button
type="button"
onClick={() =>
handleDeleteResultImage("sku_replacement", card.resultUrl!)
}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full border border-rose-400/25 bg-black/60 text-rose-100 transition-colors hover:border-rose-300/45 hover:bg-rose-500/15"
aria-label="删除结果图"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
@ -3926,12 +4042,25 @@ export function AgentGenerationWorkspaceDarkV2({
>
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
{card.url ? (
<img
src={card.url}
alt={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
<>
<img
src={card.url}
alt={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleDeleteResultImage(activeResultModeType, card.url!);
}}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full border border-rose-400/25 bg-black/60 text-rose-100 transition-colors hover:border-rose-300/45 hover:bg-rose-500/15"
aria-label="删除结果图"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
) : (
<AnimatedGenerationPlaceholderCard
badge={card.status === "generating" ? "生成中" : card.status === "queued" ? "排队中" : "待生成"}

View File

@ -51,6 +51,7 @@ export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerati
workspaceState: createEmptyAgentGenerationProjectWorkspaceState(),
resultImageUrls: [],
resultCollections: {},
deletedResultCollections: {},
};
}
@ -274,11 +275,13 @@ export function normalizeAgentGenerationProjectPersistedState(
resultImageUrls,
resultCollections: normalizeResultCollections(raw.resultCollections),
});
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
return {
workflowState,
workspaceState: normalizeWorkspaceState(raw.workspaceState),
resultImageUrls,
resultCollections,
deletedResultCollections,
};
}

View File

@ -349,6 +349,7 @@ export interface AgentGenerationProjectPersistedState {
workspaceState?: AgentGenerationProjectWorkspaceState;
resultImageUrls?: string[];
resultCollections?: AgentGenerationProjectResultCollections;
deletedResultCollections?: AgentGenerationProjectResultCollections;
}
export interface AgentGenerationProject {