refactor: simplify ai wash preview layout

This commit is contained in:
Your Name 2026-06-01 01:54:40 +08:00
parent 40190a6034
commit afc41c5a42

View File

@ -1,6 +1,7 @@
"use client";
import {
ChevronDown,
Clock3,
History as HistoryIcon,
ImageIcon,
@ -304,6 +305,22 @@ function getControlLevelByIndex(
return control.levels[clampedIndex] || control.levels[0] || null;
}
function getAiWashStatusLabel(status?: AiWashResultStatus) {
switch (status) {
case "submitting":
return "提交中";
case "running":
return "生成中";
case "succeeded":
return "已完成";
case "failed":
return "失败";
case "idle":
default:
return "待生成";
}
}
function composePresetPrompt(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
@ -392,6 +409,7 @@ export function AiWashWorkspace({
useState<Record<string, number>>({});
const [historySourceOverride, setHistorySourceOverride] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [comparePositionById, setComparePositionById] = useState<
Record<string, number>
@ -449,12 +467,18 @@ export function AiWashWorkspace({
() => createHistoryScopeKey(resolvedHistoryScopeInput),
[resolvedHistoryScopeInput]
);
const selectedPreviewResult = useMemo(
() =>
results.find((item) => item.presetId === selectedResultId) ||
results.find((item) => item.isDefault) ||
results[0] ||
null,
[results, selectedResultId]
);
const selectedResult = useMemo(
() =>
results.find(
(item) => item.presetId === selectedResultId && item.status === "succeeded"
) || null,
[results, selectedResultId]
selectedPreviewResult?.status === "succeeded" ? selectedPreviewResult : null,
[selectedPreviewResult]
);
const visibleHistoryBatches = useMemo(() => {
const scoped = persistedHistoryBatches
@ -905,6 +929,11 @@ export function AiWashWorkspace({
[persistHistoryBatches]
);
const previewComparePosition = selectedPreviewResult
? comparePositionById[selectedPreviewResult.presetId] ?? 50
: 50;
const previewHasImage = Boolean(selectedPreviewResult?.imageUrl);
return (
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
<div className="pointer-events-none absolute inset-0 bg-[#ffffff]" />
@ -1036,30 +1065,71 @@ export function AiWashWorkspace({
</div>
</div>
<div className="space-y-2">
{presetExecutionStates.map((item) => (
<div
className="rounded-[16px] border border-[#ececec] bg-white px-3 py-2.5"
key={item.preset.id}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate font-medium text-sm text-[#111111]">
{item.preset.title}
{presetExecutionStates.map((item) => {
const targetResult = results.find(
(result) => result.presetId === item.preset.id
);
const isActive =
selectedResultId === item.preset.id ||
(!selectedResultId &&
selectedPreviewResult?.presetId === item.preset.id);
return (
<button
aria-pressed={isActive}
className={cn(
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
isActive
? "border-[#111111] bg-[#111111] text-white shadow-[0_12px_24px_rgba(0,0,0,0.12)]"
: "border-[#ececec] bg-white text-[#111111] hover:border-[#111111]"
)}
key={item.preset.id}
onClick={() => setSelectedResultId(item.preset.id)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate font-medium text-sm">
{item.preset.title}
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-1 text-[11px]",
isActive
? "border-white/20 bg-white/10 text-white"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{getAiWashStatusLabel(targetResult?.status)}
</span>
</div>
<span className="shrink-0 rounded-full border border-[#ebebeb] bg-[#fafafa] px-2 py-1 text-[11px] text-[#666666]">
{item.unitCost > 0 ? `${item.unitCost} 积分/张` : "未计费"}
</span>
</div>
<div className="mt-2 flex min-w-0 items-center gap-2 text-[11px] text-[#666666]">
<span className="min-w-0 truncate rounded-full border border-[#ebebeb] bg-[#fafafa] px-2 py-1">
{item.resolvedModel?.displayName ||
item.resolvedModel?.modelId ||
(item.usesUserSelectableModel
? "前台自选模型"
: "待配置模型")}
</span>
</div>
</div>
))}
<div className="mt-2 flex min-w-0 items-center gap-2 text-[11px]">
<span
className={cn(
"min-w-0 truncate rounded-full border px-2 py-1",
isActive
? "border-white/15 bg-white/10 text-white/80"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{item.resolvedModel?.displayName ||
item.resolvedModel?.modelId ||
(item.usesUserSelectableModel
? "前台自选模型"
: "待配置模型")}
</span>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-1",
isActive
? "border-white/15 bg-white/10 text-white/80"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{item.unitCost > 0 ? `${item.unitCost} 积分/张` : "未计费"}
</span>
</div>
</button>
);
})}
</div>
</div>
@ -1178,177 +1248,179 @@ export function AiWashWorkspace({
</aside>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden px-6 pb-6 pt-6">
<div className="grid min-h-0 flex-1 gap-5 xl:grid-cols-3">
{results.length > 0 ? (
results.map((result) => {
const hasImage = Boolean(result.imageUrl);
const comparePosition = comparePositionById[result.presetId] ?? 50;
const isSelected = selectedResultId === result.presetId && hasImage;
return (
<button
className={cn(
PANEL_CLASS,
"flex min-h-0 flex-col overflow-hidden p-4 text-left transition",
isSelected &&
"border-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_18px_36px_rgba(0,0,0,0.08)]"
)}
key={result.presetId}
onClick={() => hasImage && setSelectedResultId(result.presetId)}
type="button"
>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[22px] font-semibold text-[#111111]">
{result.title}
</div>
<div className="mt-1 text-sm leading-6 text-[#666666]">
{result.description}
</div>
</div>
<div className="shrink-0 rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
{result.status === "idle"
? "待生成"
: result.status === "submitting"
? "提交中"
: result.status === "running"
? "生成中"
: result.status === "failed"
? "失败"
: "已完成"}
</div>
</div>
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-[22px] border border-[#ececec] bg-[#f7f7f7]">
{hasImage ? (
<>
<img
alt={result.title}
className="absolute inset-0 h-full w-full object-contain"
src={getDisplayImageUrl(result.imageUrl || "", 1400)}
/>
{compareSourceUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
}}
>
<img
alt={`${result.title} 原图对比`}
className="h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1400)}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.2),0_0_16px_rgba(0,0,0,0.18)]"
style={{ left: `${comparePosition}%` }}
/>
<div
className="absolute inset-0 cursor-col-resize touch-none"
onPointerCancel={(event) =>
finishCompareDrag(result.presetId, event)
}
onPointerDown={(event) =>
handleComparePointerDown(result.presetId, event)
}
onPointerMove={(event) =>
handleComparePointerMove(result.presetId, event)
}
onPointerUp={(event) =>
finishCompareDrag(result.presetId, event)
}
>
<div
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
style={{ left: `${comparePosition}%` }}
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
<div className="absolute left-1/2 top-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/70 text-white shadow-[0_12px_24px_rgba(0,0,0,0.2)]">
<RefreshCcw className="h-4 w-4" />
</div>
</div>
</div>
<div className="pointer-events-none absolute left-3 top-3 rounded-full bg-black/65 px-2.5 py-1 text-[11px] text-white">
原图
</div>
<div className="pointer-events-none absolute right-3 top-3 rounded-full bg-black/65 px-2.5 py-1 text-[11px] text-white">
洗后
</div>
</>
) : null}
</>
) : (
<div className="flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(180deg,#ffffff,#f7f7f7)]">
<div className="px-6 text-center">
{result.status === "running" || result.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
<div className="text-sm text-[#666666]">
{result.status === "submitting"
? "正在提交模型任务"
: "模型正在生成结果"}
</div>
</>
) : result.status === "failed" ? (
<div className="text-sm leading-6 text-[#9b2c2c]">
{result.error || "该预设生成失败"}
</div>
) : (
<>
<ImageIcon className="mx-auto mb-3 h-8 w-8 text-[#b2b2b2]" />
<div className="text-sm text-[#888888]">
生成后会在这里完整展示可拖动对比图
</div>
</>
)}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-[#666666]">
<div className="flex flex-wrap items-center gap-2">
{result.modelId ? (
<span className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-2.5 py-1">
{result.modelId}
</span>
) : null}
{isSelected ? (
<span className="rounded-full border border-[#111111] bg-white px-2.5 py-1 text-[#111111]">
当前发送目标
</span>
) : null}
</div>
{result.createdAt ? (
<span>
{new Date(result.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
) : null}
</div>
</button>
);
})
) : (
<div className={cn(PANEL_CLASS, "col-span-full p-10 text-center")}>
<Sparkles className="mx-auto mb-4 h-8 w-8 text-[#8a8a8a]" />
<div className="text-lg font-medium text-[#111111]">AI洗图准备就绪</div>
<div className="mt-2 text-sm text-[#666666]">
上传底图后,系统会按管理员配置好的多个模式同时生成洗图结果。
<section
className={cn(
PANEL_CLASS,
"flex min-h-0 flex-1 flex-col overflow-hidden p-4"
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-[24px] font-semibold leading-tight text-[#111111]">
{selectedPreviewResult?.title || "AI洗图结果预览"}
</div>
<span className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
{getAiWashStatusLabel(selectedPreviewResult?.status)}
</span>
</div>
<div className="mt-1 max-w-3xl text-sm leading-6 text-[#666666]">
{selectedPreviewResult?.description ||
"左侧选择渲染目标后,所有洗图结果都会共用这里的大尺寸预览区。"}
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-[#666666]">
{selectedPreviewResult?.modelId ? (
<span className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1.5">
{selectedPreviewResult.modelId}
</span>
) : null}
{selectedPreviewResult?.createdAt ? (
<span className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1.5">
{new Date(selectedPreviewResult.createdAt).toLocaleTimeString(
"zh-CN",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</span>
) : null}
</div>
</div>
<div className="relative min-h-[560px] flex-1 overflow-hidden rounded-[28px] border border-[#e9e9e9] bg-[radial-gradient(circle_at_50%_0%,#ffffff_0%,#f8f8f8_52%,#f2f2f2_100%)]">
{selectedPreviewResult ? (
previewHasImage ? (
<>
<img
alt={selectedPreviewResult.title}
className="absolute inset-0 h-full w-full object-contain"
src={getDisplayImageUrl(
selectedPreviewResult.imageUrl || "",
1800
)}
/>
{compareSourceUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - previewComparePosition}% 0 0)`,
}}
>
<img
alt={`${selectedPreviewResult.title} 原图对比`}
className="h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.22),0_0_20px_rgba(0,0,0,0.18)]"
style={{ left: `${previewComparePosition}%` }}
/>
<div
className="absolute inset-0 cursor-col-resize touch-none"
onPointerCancel={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
onPointerDown={(event) =>
handleComparePointerDown(
selectedPreviewResult.presetId,
event
)
}
onPointerMove={(event) =>
handleComparePointerMove(
selectedPreviewResult.presetId,
event
)
}
onPointerUp={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
>
<div
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
style={{ left: `${previewComparePosition}%` }}
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
<div className="absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/72 text-white shadow-[0_14px_28px_rgba(0,0,0,0.24)]">
<RefreshCcw className="h-4 w-4" />
</div>
</div>
</div>
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
原图
</div>
<div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
洗后
</div>
</>
) : null}
</>
) : (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
<div className="max-w-sm">
{selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-4 h-9 w-9 animate-spin text-[#666666]" />
<div className="text-base font-medium text-[#111111]">
{selectedPreviewResult.status === "submitting"
? "正在提交模型任务"
: "模型正在生成结果"}
</div>
<div className="mt-2 text-sm leading-6 text-[#777777]">
生成完成后,这里会自动切换为大图对比预览。
</div>
</>
) : selectedPreviewResult.status === "failed" ? (
<>
<div className="text-base font-medium text-[#9b2c2c]">
该目标生成失败
</div>
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
{selectedPreviewResult.error || "请调整配置后重新生成。"}
</div>
</>
) : (
<>
<ImageIcon className="mx-auto mb-4 h-10 w-10 text-[#b2b2b2]" />
<div className="text-base font-medium text-[#111111]">
固定单图预览区
</div>
<div className="mt-2 text-sm leading-6 text-[#777777]">
先上传底图并点击开始 AI洗图,结果会在这里完整展示。
</div>
</>
)}
</div>
</div>
)
) : (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
<div>
<Sparkles className="mx-auto mb-4 h-10 w-10 text-[#9a9a9a]" />
<div className="text-base font-medium text-[#111111]">
AI洗图准备就绪
</div>
<div className="mt-2 text-sm text-[#777777]">
管理员配置预设后,这里会展示共享结果预览。
</div>
</div>
</div>
)}
</div>
</section>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="rounded-full border border-[#e5e5e5] bg-white px-4 py-2 text-xs text-[#666666] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_20px_rgba(0,0,0,0.03)]">
{selectedResult
? `已选中 ${selectedResult.title},可直接发送回画布`
: "请选择或生成一张洗图结果"}
: selectedPreviewResult
? `当前预览 ${selectedPreviewResult.title},生成完成后可发送回画布`
: "请选择或生成一张洗图结果"}
</div>
<Button
className={cn(
@ -1365,8 +1437,12 @@ export function AiWashWorkspace({
</Button>
</div>
<div className={cn(PANEL_CLASS, "mt-5 p-4")}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className={cn(PANEL_CLASS, "mt-4 overflow-hidden p-3")}>
<button
className="flex w-full items-center justify-between gap-3 rounded-[22px] px-1 py-1 text-left transition hover:bg-[#fafafa]"
onClick={() => setIsHistoryExpanded((current) => !current)}
type="button"
>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-[16px] border border-[#ececec] bg-white shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<HistoryIcon className="h-5 w-5 text-[#111111]" />
@ -1380,13 +1456,25 @@ export function AiWashWorkspace({
</div>
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
最近 {visibleHistoryBatches.length} 条
<div className="flex shrink-0 items-center gap-2">
<div className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
最近 {visibleHistoryBatches.length} 条
</div>
<div className="inline-flex items-center gap-1 rounded-full border border-[#ebebeb] bg-white px-3 py-1 text-xs text-[#666666]">
{isHistoryExpanded ? "收起" : "展开"}
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform",
isHistoryExpanded && "rotate-180"
)}
/>
</div>
</div>
</div>
</button>
{visibleHistoryBatches.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1">
{isHistoryExpanded ? (
visibleHistoryBatches.length > 0 ? (
<div className="mt-3 flex gap-3 overflow-x-auto border-t border-[#eeeeee] pt-3 pb-1">
{visibleHistoryBatches.map((entry) => {
const cover = entry.items[0];
return (
@ -1457,12 +1545,13 @@ export function AiWashWorkspace({
</div>
);
})}
</div>
) : (
<div className="rounded-[22px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)}
</div>
) : (
<div className="mt-3 rounded-[22px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)
) : null}
</div>
</main>
</div>