fix: polish ai wash layout and sidebar overflow

This commit is contained in:
Your Name 2026-06-01 16:37:09 +08:00
parent a701ddde50
commit 42adff8545
2 changed files with 299 additions and 395 deletions

View File

@ -24,7 +24,6 @@ import {
} from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
@ -53,8 +52,6 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
const PANEL_CLASS =
"rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.06)]";
const SUBTLE_PANEL_CLASS =
"rounded-[22px] border border-[#ececec] bg-[#fbfbfb] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]";
const PRIMARY_ACTION_BUTTON_CLASS =
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none";
const SECONDARY_ACTION_BUTTON_CLASS =
@ -933,6 +930,13 @@ export function AiWashWorkspace({
? comparePositionById[selectedPreviewResult.presetId] ?? 50
: 50;
const previewHasImage = Boolean(selectedPreviewResult?.imageUrl);
const selectedExecutionState =
presetExecutionStates.find(
(item) => item.presetId === selectedPreviewResult?.presetId
) ||
presetExecutionStates.find((item) => item.preset.isDefault) ||
presetExecutionStates[0] ||
null;
return (
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
@ -940,8 +944,13 @@ export function AiWashWorkspace({
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025]" />
<div className="relative z-10 flex h-full w-full pt-24">
<aside className="relative z-20 order-2 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pr-6">
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}>
<aside className="relative z-20 order-2 -mt-16 h-[calc(100%+4rem)] w-[430px] max-w-[42vw] shrink-0 pb-6 pr-6">
<div
className={cn(
"h-full space-y-4 overflow-y-auto rounded-[36px] border border-[#e7e7e7] bg-white/72 p-4 shadow-[0_24px_70px_rgba(0,0,0,0.08)] backdrop-blur-xl",
SIDEBAR_SCROLL_CLASS
)}
>
{isLoading ? (
<div
className={cn(
@ -966,42 +975,36 @@ export function AiWashWorkspace({
</div>
) : null}
<div className="space-y-3">
<div className={cn(PANEL_CLASS, "space-y-3 p-3.5")}>
<div className="flex items-start justify-between gap-3">
<div className="space-y-4">
<div className="rounded-[30px] border border-[#e8e8e8] bg-[#fbfbfb]/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Sparkles}
iconUrl={config.modeIconUrl}
/>
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[22px] border border-[#ececec] bg-white text-[#111111] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
<SlidersHorizontal className="h-6 w-6" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-[22px] text-[#111111]">
{config.modeLabel || "AI洗图"}
<div className="inline-flex rounded-full border border-[#e6e6e6] bg-white px-3 py-1 font-semibold text-[11px] text-[#777777] tracking-[0.18em]">
STUDIO SETUP
</div>
<div className="mt-2 font-semibold text-[20px] text-[#111111]">
设置
</div>
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]">
{enabledPresets.length} 个预设
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-full border border-[#e6e6e6] bg-white px-3 py-1.5 text-xs text-[#666666]">
Redefine
</span>
<ChevronDown className="h-4 w-4 rotate-180 text-[#666666]" />
</div>
</div>
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-2 p-3.5")}>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-[#111111]">
渲染目标
</div>
</div>
<div className="rounded-[26px] border border-[#ececec] bg-[#fbfbfb]/92 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
<div className="mb-3 font-semibold text-[17px] text-[#111111]">
增强预设
</div>
<div className="space-y-2">
<div className="space-y-2.5">
{presetExecutionStates.map((item) => {
const targetResult = results.find(
(result) => result.presetId === item.preset.id
@ -1014,72 +1017,46 @@ export function AiWashWorkspace({
<button
aria-pressed={isActive}
className={cn(
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
"w-full rounded-[18px] border px-4 py-3 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]"
? "border-[#111111] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.08)]"
: "border-[#e8e8e8] bg-white text-[#666666] hover:border-[#111111] hover:text-[#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">
<div className="min-w-0 truncate font-medium text-[15px]">
{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-[#111111] bg-[#111111] text-white"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{getAiWashStatusLabel(targetResult?.status)}
</span>
</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 className="mt-3 max-h-24 overflow-hidden rounded-[18px] border border-[#e9e9e9] bg-white px-4 py-3 text-sm leading-6 text-[#666666]">
{selectedExecutionState?.resolvedPrompt ||
"选择预设后会使用管理员配置的提示词进行 AI洗图。"}
</div>
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-3 p-3.5")}>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]">
<Settings2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-[#111111]">
参数设置
</div>
<div className="rounded-[26px] border border-[#ececec] bg-[#fbfbfb]/92 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
<div className="mb-3 flex min-w-0 items-center justify-between gap-3">
<div className="font-semibold text-[17px] text-[#111111]">
参数设置
</div>
<Settings2 className="h-4 w-4 text-[#777777]" />
</div>
{shouldShowUserModelSelector ? (
@ -1148,7 +1125,7 @@ export function AiWashWorkspace({
null;
return (
<div
className="rounded-[16px] border border-[#ececec] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]"
className="rounded-[20px] border border-[#ececec] bg-white px-4 py-3.5 shadow-[0_8px_18px_rgba(0,0,0,0.03)]"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-3">
@ -1184,361 +1161,288 @@ export function AiWashWorkspace({
</div>
</aside>
<main className="order-1 flex min-w-0 flex-1 flex-col gap-3 overflow-hidden px-6 pb-6 pt-6">
<section
className={cn(
PANEL_CLASS,
"flex min-h-0 flex-1 flex-col overflow-hidden p-3"
)}
>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[28px] border border-[#e9e9e9] bg-[radial-gradient(circle_at_50%_0%,#ffffff_0%,#f8f8f8_52%,#f2f2f2_100%)]">
{compareSourceUrl ? (
<button
className={cn(
"absolute right-4 top-4 z-20 inline-flex h-10 items-center rounded-full px-4 text-sm font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
onClick={() => onRequestAddImage?.()}
type="button"
>
<Upload className="mr-2 h-4 w-4" />
更换图片
</button>
) : null}
{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}%` }}
<main className="order-1 relative min-w-0 flex-1 overflow-hidden px-8 pb-7 pt-6">
<div className="relative h-full w-full">
{compareSourceUrl ? (
<button
className={cn(
"absolute right-4 top-2 z-20 inline-flex h-10 items-center rounded-full px-4 text-sm font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
onClick={() => onRequestAddImage?.()}
type="button"
>
<Upload className="mr-2 h-4 w-4" />
更换图片
</button>
) : null}
<div className="absolute inset-0 flex items-center justify-center px-8 pb-44 pt-8">
{selectedPreviewResult && previewHasImage ? (
<div className="relative max-h-full max-w-full overflow-hidden rounded-[24px] shadow-[0_18px_52px_rgba(0,0,0,0.08)]">
<img
alt={selectedPreviewResult.title}
className="block max-h-[calc(100vh-320px)] max-w-[min(64vw,1120px)] 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="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)
}
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
style={{ left: `${previewComparePosition}%` }}
>
<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 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 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}
</>
) : compareSourceUrl ? (
<>
<img
alt="AI洗图底图"
className="absolute inset-0 h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
<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>
{(selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ||
selectedPreviewResult.status === "failed") ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/58 px-6 text-center backdrop-blur-[1px]">
<div className="max-w-sm rounded-[24px] border border-[#ececec] bg-white/92 px-6 py-5 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 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>
</>
) : (
<>
<div className="text-base font-medium text-[#9b2c2c]">
该目标生成失败
</div>
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
{selectedPreviewResult.error || "请调整配置后重新生成。"}
</div>
</>
)}
</div>
</div>
) : (
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 py-2 text-xs text-[#666666] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
原图已就绪,点击下方开始 AI洗图
<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="absolute inset-0 flex items-center justify-center px-6 text-center">
<div>
<button
className="inline-flex h-16 items-center rounded-[26px] bg-[#111111] px-10 text-[18px] font-semibold text-white shadow-[0_18px_40px_rgba(0,0,0,0.16)] transition hover:bg-black"
onClick={() => onRequestAddImage?.()}
type="button"
>
<ImageIcon className="mr-3 h-5 w-5" />
添加图片
</button>
<div className="mt-4 text-sm text-[#777777]">
选择本地图后即可开始 AI洗图
<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>
</div>
</div>
)
</>
) : null}
</div>
) : compareSourceUrl ? (
<>
<div className="relative max-h-full max-w-full">
<img
alt="AI洗图底图"
className="absolute inset-0 h-full w-full object-contain"
className="block max-h-[calc(100vh-320px)] max-w-[min(64vw,1120px)] rounded-[24px] object-contain shadow-[0_18px_52px_rgba(0,0,0,0.08)]"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
<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 bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 py-2 text-xs text-[#666666] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
原图已就绪,点击下方开始 AI洗图
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
<div>
<button
className="inline-flex h-16 items-center rounded-[26px] bg-[#111111] px-10 text-[18px] font-semibold text-white shadow-[0_18px_40px_rgba(0,0,0,0.16)] transition hover:bg-black"
onClick={() => onRequestAddImage?.()}
type="button"
>
<ImageIcon className="mr-3 h-5 w-5" />
添加图片
</button>
<div className="mt-4 text-sm text-[#777777]">
选择本地图后即可开始 AI洗图
{selectedPreviewResult?.status === "running" ||
selectedPreviewResult?.status === "submitting" ||
selectedPreviewResult?.status === "failed" ? (
<div className="absolute inset-0 flex items-center justify-center rounded-[24px] bg-white/58 px-6 text-center backdrop-blur-[1px]">
<div className="max-w-sm rounded-[24px] border border-[#ececec] bg-white/92 px-6 py-5 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 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>
</>
) : (
<>
<div className="text-base font-medium text-[#9b2c2c]">
该目标生成失败
</div>
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
{selectedPreviewResult.error || "请调整配置后重新生成。"}
</div>
</>
)}
</div>
</div>
) : null}
</div>
) : (
<div className="text-center">
<button
className="inline-flex h-[92px] items-center rounded-[28px] bg-[#111111] px-12 text-[22px] font-semibold text-white shadow-[0_22px_56px_rgba(0,0,0,0.16)] transition hover:bg-black"
onClick={() => onRequestAddImage?.()}
type="button"
>
<ImageIcon className="mr-4 h-6 w-6" />
添加图片
</button>
<div className="mt-5 text-[15px] text-[#777777]">
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
</div>
</div>
)}
</div>
</section>
<div className="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
? compareSourceUrl
? `当前目标 ${selectedPreviewResult.title},生成完成后可发送回画布`
: "请先添加一张底图"
: compareSourceUrl
? "原图已上传,点击开始 AI洗图生成结果"
: "请先添加一张底图"}
<div className="absolute bottom-0 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-2">
<div className="rounded-full border border-[#e5e5e5] bg-white/92 px-5 py-2 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur">
生成后可直接发送回画布
</div>
<div className="rounded-full border border-[#e5e5e5] bg-white/92 px-5 py-2 text-xs text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur">
{totalEstimatedCost > 0
? `预计消耗 ${totalEstimatedCost} 积分`
: "预计消耗按模型配置计算"}
</div>
<div className="flex items-center gap-3 rounded-[34px] border border-[#e6e6e6] bg-white/88 p-3 shadow-[0_18px_46px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<Button
className={cn(
"h-14 min-w-[220px] rounded-[26px] px-8 text-[15px] font-semibold",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
(shouldShowUserModelSelector && !selectedUserModel)
}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmitting ? "正在洗图" : "开始 AI洗图"}
</Button>
<Button
className={cn(
"h-14 min-w-[220px] rounded-[26px] px-8 text-[15px] font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedResult?.imageUrl}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
className={cn(
"h-11 rounded-[18px] px-5 text-sm font-semibold",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
(shouldShowUserModelSelector && !selectedUserModel)
}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmitting
? "正在洗图"
: totalEstimatedCost > 0
? `开始 AI洗图 · ${totalEstimatedCost} 积分`
: "开始 AI洗图"}
</Button>
<Button
className={cn(
"h-11 rounded-[18px] px-5 text-sm font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedResult?.imageUrl}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
<div className={cn(PANEL_CLASS, "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]"
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
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]" />
</div>
<div>
<div className="text-lg font-semibold text-[#111111]">
历史洗图记录
</div>
<div className="text-sm text-[#666666]">
保留最近的 AI洗图批次,支持一键回看并继续发送结果到画布。
</div>
</div>
</div>
<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>
<HistoryIcon className="h-4 w-4" />
历史 {visibleHistoryBatches.length}
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform",
isHistoryExpanded && "rotate-180"
)}
/>
</button>
{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 (
<div
className="min-w-[260px] max-w-[260px] rounded-[22px] border border-[#ececec] bg-[#fbfbfb] p-3"
key={entry.id}
>
<button
className="w-full text-left"
onClick={() => handleLoadHistoryBatch(entry)}
type="button"
>
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
<img
alt={cover?.title || "历史洗图结果"}
className="aspect-[4/3] w-full object-cover"
src={getDisplayImageUrl(
cover?.thumbnailUrl || cover?.imageUrl || "",
520
)}
/>
</div>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-sm text-[#111111]">
{cover?.title || "AI洗图结果"}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
<Clock3 className="h-3.5 w-3.5" />
{new Date(entry.createdAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{entry.items.length} 张
</div>
</div>
</button>
<div className="mt-3 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
{cover?.modelId ? (
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{cover.modelId}
</span>
) : null}
{entry.sourceImageUrl ? (
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
可对比
</span>
) : null}
</div>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
onClick={(event) => {
event.stopPropagation();
handleDeleteHistoryBatch(entry.id);
}}
title="删除历史记录"
type="button"
<div className="absolute bottom-16 left-2 z-40 w-[380px] rounded-[28px] border border-[#e5e5e5] bg-white/94 p-3 shadow-[0_24px_60px_rgba(0,0,0,0.12)] backdrop-blur-xl">
{visibleHistoryBatches.length > 0 ? (
<div className="flex max-h-[360px] flex-col gap-3 overflow-y-auto pr-1">
{visibleHistoryBatches.map((entry) => {
const cover = entry.items[0];
return (
<div
className="rounded-[20px] border border-[#ececec] bg-[#fbfbfb] p-3"
key={entry.id}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</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>
)
<button
className="flex w-full items-center gap-3 text-left"
onClick={() => handleLoadHistoryBatch(entry)}
type="button"
>
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-[14px] border border-[#ececec] bg-white">
<img
alt={cover?.title || "历史洗图结果"}
className="h-full w-full object-cover"
src={getDisplayImageUrl(
cover?.thumbnailUrl || cover?.imageUrl || "",
360
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm text-[#111111]">
{cover?.title || "AI洗图结果"}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
<Clock3 className="h-3.5 w-3.5" />
{new Date(entry.createdAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{entry.items.length} 张
</div>
</button>
<div className="mt-2 flex items-center justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{cover?.modelId ? (
<span className="max-w-[240px] truncate rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{cover.modelId}
</span>
) : null}
{entry.sourceImageUrl ? (
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
可对比
</span>
) : null}
</div>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
onClick={(event) => {
event.stopPropagation();
handleDeleteHistoryBatch(entry.id);
}}
title="删除历史记录"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-[20px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)}
</div>
) : null}
</div>
</main>

View File

@ -122,7 +122,7 @@ export function FloatingSidebarRail({
: "border border-white/8 bg-[#17181d]/94 text-white shadow-[0_22px_60px_rgba(6,8,14,0.34),inset_0_1px_0_rgba(255,255,255,0.05)]",
isHorizontal
? "h-[56px] w-auto flex-row overflow-hidden rounded-[22px] px-3 py-2"
: "w-[56px] flex-col overflow-y-auto rounded-[24px] px-2 py-2",
: "w-[56px] flex-col overflow-x-hidden overflow-y-auto overscroll-contain rounded-[24px] px-2 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className
)}
>