refactor: align ai wash workspace styling

This commit is contained in:
Your Name 2026-06-01 01:14:07 +08:00
parent 1c7d005088
commit 40190a6034

View File

@ -1,13 +1,14 @@
"use client";
import {
CheckCircle2,
Clock3,
History as HistoryIcon,
ImageIcon,
Loader2,
RefreshCcw,
Send,
Settings2,
SlidersHorizontal,
Sparkles,
Trash2,
Upload,
@ -50,9 +51,23 @@ import { cn } from "@/lib/utils";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const PANEL_CLASS =
"rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_20px_40px_rgba(0,0,0,0.22)] backdrop-blur-xl";
"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-[24px] border border-white/10 bg-white/[0.03] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]";
"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 =
"border border-[#d9d9d9] bg-white text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_22px_rgba(0,0,0,0.03)] transition hover:border-[#111111] hover:bg-[#fafafa] hover:text-black disabled:border-[#e5e5e5] disabled:bg-[#f7f7f7] disabled:text-[#a5a5a5] disabled:shadow-none";
const FIELD_CLASS =
"h-10 rounded-[14px] border-[#e5e5e5] bg-white text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(0,0,0,0.04)]";
const SELECT_CONTENT_CLASS =
"border-[#e5e5e5] bg-white text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_18px_38px_rgba(0,0,0,0.08)]";
const SIDEBAR_SCROLL_CLASS =
"pr-1.5 [scrollbar-width:thin] [scrollbar-color:#c8c8c8_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#c8c8c8] hover:[&::-webkit-scrollbar-thumb]:bg-[#a8a8a8]";
const SLIDER_TRACK_CLASS = "h-2.5 bg-[#ededed] shadow-inner";
const SLIDER_RANGE_CLASS = "bg-[#111111]";
const SLIDER_THUMB_CLASS =
"h-4 w-4 border border-[#d4d4d4] bg-white shadow-[0_0_0_4px_rgba(0,0,0,0.08)]";
const AI_WASH_HISTORY_STORAGE_KEY = "ai-chatbot:ai-wash-history:v1";
const AI_WASH_HISTORY_TOTAL_LIMIT = 24;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
@ -891,432 +906,459 @@ export function AiWashWorkspace({
);
return (
<div className="relative flex h-full w-full bg-[#0a0c12] text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(95,99,255,0.16),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.14),transparent_22%),linear-gradient(180deg,#0b0d13_0%,#07090e_100%)]" />
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
<div className="pointer-events-none absolute inset-0 bg-[#ffffff]" />
<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="flex h-full w-[320px] shrink-0 border-white/8 border-r px-5 pb-6">
<div className="flex h-full w-full flex-col gap-4 overflow-y-auto pr-1">
<div className={cn(PANEL_CLASS, "p-4")}>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05]">
<aside className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pl-6">
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}>
{isLoading ? (
<div
className={cn(
PANEL_CLASS,
"flex items-center gap-2 px-4 py-3 text-[#666666] text-sm"
)}
>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载 AI洗图 配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-[24px] border border-[#e5e5e5] bg-white px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
当前未启用 AI洗图。请先在系统设置的“本地AI设置”里开启并配置预设。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-[24px] border border-[#f0d4d4] bg-[#fff7f7] px-4 py-3 text-[#9b2c2c] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
{lastErrorMessage}
</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="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 text-white"
className="h-5 w-5"
fallback={Sparkles}
iconUrl={config.modeIconUrl}
/>
</div>
<div className="min-w-0 text-[30px] font-semibold tracking-[-0.05em]">
{config.modeLabel || "AI洗图"}
<div className="min-w-0">
<div className="truncate font-semibold text-[22px] text-[#111111]">
{config.modeLabel || "AI洗图"}
</div>
</div>
</div>
<div className="mt-1 text-sm text-white/58">
使用管理员预设好的模型与提示词,生成多路洗图结果并拖动对比。
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]">
{enabledPresets.length} 个预设
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs text-white/70">
{enabledPresets.length} 个预设
<Button
className={cn(
"h-12 w-full rounded-[20px] px-4 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
? "正在洗图"
: totalEstimatedCost > 0
? `开始 AI洗图 · ${totalEstimatedCost} 积分`
: "开始 AI洗图"}
</Button>
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-3 p-3.5")}>
<div className="flex items-center justify-between">
<div className="text-[15px] font-semibold text-[#111111]">
上传底图
</div>
<Button
className={cn(
"h-9 rounded-full px-3 text-xs font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
onClick={() => onRequestAddImage?.()}
size="sm"
type="button"
variant="outline"
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{compareSourceUrl ? "更换图片" : "选择图片"}
</Button>
</div>
{compareSourceUrl ? (
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
<img
alt="AI洗图底图"
className="aspect-[16/10] w-full object-cover"
src={getDisplayImageUrl(compareSourceUrl, 720)}
/>
</div>
) : (
<button
className="flex aspect-[16/10] w-full items-center justify-center rounded-[18px] border border-dashed border-[#d9d9d9] bg-white text-center text-sm text-[#8a8a8a] transition hover:border-[#111111] hover:text-[#111111]"
onClick={() => onRequestAddImage?.()}
type="button"
>
先上传一张底图
</button>
)}
</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>
<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}
</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>
</div>
<Button
className="h-12 w-full rounded-[18px] border-0 bg-white text-[#111111] hover:bg-white/90"
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>
<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>
</div>
<div className="mt-4 space-y-3">
<div className={cn(SUBTLE_PANEL_CLASS, "p-4")}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05]">
<CanvasToolbarIcon
className="h-5 w-5 text-white/80"
fallback={Sparkles}
iconUrl={config.modeIconUrl}
{shouldShowUserModelSelector ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-[12px] font-medium text-[#666666]">
生图模型
</div>
{selectedUserModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-1 text-[11px] text-emerald-700">
{selectedUserModel.creditDiscountLabel}
</span>
) : null}
</div>
<Select
onValueChange={setSelectedUserModelId}
value={selectedUserModel?.id || selectedUserModelId}
>
<SelectTrigger className={FIELD_CLASS}>
<SelectValue placeholder="选择生图模型" />
</SelectTrigger>
<SelectContent className={SELECT_CONTENT_CLASS}>
{selectableModelOptions.map((model) => (
<SelectItem
disabled={model.selectable === false}
key={model.id}
value={model.id}
>
<div className="flex w-full min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
<span className="shrink-0 text-[#777777] text-xs">
{model.unitCost > 0
? `${model.unitCost} 积分/张`
: "未计费"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-[12px] leading-5 text-[#666666]">
{selectedUserModel
? `当前模型:${selectedUserModel.displayName} · ${
selectedUserModel.unitCost > 0
? `${selectedUserModel.unitCost} 积分/张`
: "未设置积分"
}`
: modelOptionsLoading
? "正在加载可选模型..."
: "没有可选模型"}
</div>
</div>
) : (
null
)}
<div className="space-y-3">
{uiControls.map((control) => {
const selectedIndex =
selectedLevelIndexByControl[control.id] ?? control.defaultLevel;
const activeLevel =
getControlLevelByIndex(control, selectedIndex) ||
control.levels[0] ||
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)]"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-[#222222]">
{control.label}
</div>
<div className="inline-flex min-w-[54px] justify-center rounded-full border border-[#e7e7e7] bg-white px-2.5 py-1 font-medium text-[11px] text-[#666666] shadow-[0_4px_12px_rgba(0,0,0,0.04)]">
{activeLevel?.label || selectedIndex + 1}
</div>
</div>
<Slider
className="w-full"
max={Math.max(0, control.levels.length - 1)}
min={0}
onValueChange={([next]) =>
setSelectedLevelIndexByControl((current) => ({
...current,
[control.id]: Math.round(next ?? 0),
}))
}
rangeClassName={SLIDER_RANGE_CLASS}
step={1}
thumbClassName={SLIDER_THUMB_CLASS}
trackClassName={SLIDER_TRACK_CLASS}
value={[selectedIndex]}
/>
</div>
<div className="min-w-0">
<div className="text-[11px] font-semibold tracking-[0.22em] text-white/45 uppercase">
Fine Tune
</div>
<div className="truncate text-lg font-semibold tracking-[-0.03em]">
{config.modeLabel || "AI洗图"}
</div>
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-sm font-semibold text-white/88">
{totalEstimatedCost > 0 ? `${totalEstimatedCost} 积分` : "待计费"}
</div>
</div>
{shouldShowUserModelSelector ? (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">生图模型</div>
{selectedUserModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-400/10 px-2 py-1 text-[11px] text-emerald-100">
{selectedUserModel.creditDiscountLabel}
</span>
) : null}
</div>
<Select
onValueChange={setSelectedUserModelId}
value={selectedUserModel?.id || selectedUserModelId}
>
<SelectTrigger className="h-11 rounded-[16px] border-white/10 bg-white/[0.04] text-white">
<SelectValue placeholder="选择生图模型" />
</SelectTrigger>
<SelectContent className="border-white/10 bg-[#11141b] text-white">
{selectableModelOptions.map((model) => (
<SelectItem
disabled={model.selectable === false}
key={model.id}
value={model.id}
>
<div className="flex w-full min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
<span className="shrink-0 text-white/52 text-xs">
{model.unitCost > 0
? `${model.unitCost} 积分/张`
: "未计费"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-[12px] leading-5 text-white/52">
{selectedUserModel
? `当前模型:${selectedUserModel.displayName} · ${
selectedUserModel.unitCost > 0
? `${selectedUserModel.unitCost} 积分/张`
: "未设置积分"
}`
: modelOptionsLoading
? "正在加载可选模型..."
: "没有可选模型"}
</div>
</div>
) : (
<div className="mb-4 rounded-[18px] border border-white/10 bg-white/[0.04] px-3.5 py-3 text-[12px] leading-5 text-white/62">
管理员已经为当前洗图流程指定了模型,前台不再展示模型选择。
</div>
)}
<div className="space-y-3">
{uiControls.map((control) => {
const selectedIndex =
selectedLevelIndexByControl[control.id] ?? control.defaultLevel;
const activeLevel =
getControlLevelByIndex(control, selectedIndex) ||
control.levels[0] ||
null;
return (
<div
className="rounded-[20px] border border-white/10 bg-white/[0.03] px-3.5 py-3"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white/88">
{control.label}
</div>
<div className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-xs font-semibold text-white/78">
{activeLevel?.label || selectedIndex + 1}
</div>
</div>
<Slider
className="w-full"
max={Math.max(0, control.levels.length - 1)}
min={0}
onValueChange={([next]) =>
setSelectedLevelIndexByControl((current) => ({
...current,
[control.id]: Math.round(next ?? 0),
}))
}
step={1}
value={[selectedIndex]}
/>
<div className="mt-2 text-[11px] leading-5 text-white/46">
{control.helperText}
</div>
</div>
);
})}
</div>
);
})}
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "p-4")}>
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium">上传底图</div>
<Button
className="h-8 rounded-full border-white/10 bg-white/[0.05] px-3 text-white hover:bg-white/[0.1]"
onClick={() => onRequestAddImage?.()}
size="sm"
type="button"
variant="outline"
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{compareSourceUrl ? "更换图片" : "选择图片"}
</Button>
</div>
{compareSourceUrl ? (
<div className="overflow-hidden rounded-[18px] border border-white/10 bg-black/20">
<img
alt="AI洗图底图"
className="aspect-[4/5] w-full object-cover"
src={getDisplayImageUrl(compareSourceUrl, 720)}
/>
</div>
) : (
<div className="flex aspect-[4/5] items-center justify-center rounded-[18px] border border-dashed border-white/12 bg-white/[0.02] text-center text-sm text-white/45">
先上传一张底图
</div>
)}
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "p-4")}>
<div className="mb-3 text-sm font-medium">渲染目标</div>
<div className="space-y-2">
{presetExecutionStates.map((item) => (
<div
className="rounded-[18px] border border-white/10 bg-white/[0.04] px-3 py-3"
key={item.preset.id}
>
<div className="font-medium text-sm text-white">
{item.preset.title}
</div>
<div className="mt-1 text-[12px] leading-5 text-white/55">
{item.preset.description}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-white/55">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-1">
{item.resolvedModel?.displayName ||
item.resolvedModel?.modelId ||
(item.usesUserSelectableModel
? "前台自选模型"
: "待配置模型")}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-1">
{item.unitCost > 0 ? `${item.unitCost} 积分/张` : "未计费"}
</span>
</div>
</div>
))}
</div>
</div>
{lastErrorMessage ? (
<div className="rounded-[18px] border border-rose-400/18 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">
{lastErrorMessage}
</div>
) : null}
</div>
</div>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden px-6 pb-6">
<div className="flex-1 overflow-y-auto">
<div className="grid 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;
<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-[560px] flex-col overflow-hidden p-4 text-left transition",
isSelected && "ring-2 ring-white/70"
)}
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 tracking-[-0.04em]">
{result.title}
</div>
<div className="mt-1 text-sm leading-6 text-white/58">
{result.description}
</div>
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="shrink-0 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs text-white/72">
{result.status === "idle"
? "待生成"
: result.status === "submitting"
? "提交中"
: result.status === "running"
? "生成中"
: result.status === "failed"
? "失败"
: "已完成"}
<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 flex-1 overflow-hidden rounded-[22px] border border-white/10 bg-black/25">
{hasImage ? (
<>
<img
alt={result.title}
className="absolute inset-0 h-full w-full object-cover"
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-cover"
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.3),0_0_16px_rgba(0,0,0,0.3)]"
style={{ left: `${comparePosition}%` }}
<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="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)
}
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
style={{ left: `${comparePosition}%` }}
>
<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/95" />
<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 bg-[#5b67ff] text-white shadow-[0_12px_28px_rgba(91,103,255,0.45)]">
<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-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 className="pointer-events-none absolute left-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[11px] text-white/92">
原图
</div>
<div className="pointer-events-none absolute right-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[11px] text-white/92">
洗后
</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>
</>
) : null}
</>
) : (
<div className="flex h-full min-h-[440px] items-center justify-center bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.01))]">
<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-white/70" />
<div className="text-sm text-white/72">
{result.status === "submitting"
? "正在提交模型任务"
: "模型正在生成结果"}
</div>
</>
) : result.status === "failed" ? (
<div className="text-sm leading-6 text-rose-100/90">
{result.error || "该预设生成失败"}
) : 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>
) : (
<>
<ImageIcon className="mx-auto mb-3 h-8 w-8 text-white/45" />
<div className="text-sm text-white/58">
生成后会在这里展示可拖动对比图
</div>
</>
)}
</div>
</>
)}
</div>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-white/58">
<div className="flex flex-wrap items-center gap-2">
{result.modelId ? (
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1">
{result.modelId}
</span>
) : null}
{isSelected ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-100">
当前发送目标
</span>
) : null}
</div>
{result.createdAt ? (
<span>
{new Date(result.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
)}
</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>
</button>
);
})
) : (
<div className={cn(PANEL_CLASS, "col-span-full p-10 text-center")}>
<Sparkles className="mx-auto mb-4 h-8 w-8 text-white/55" />
<div className="text-lg font-medium">AI洗图准备就绪</div>
<div className="mt-2 text-sm text-white/58">
上传底图后,系统会按管理员配置好的多个模型预设同时生成洗图结果。
</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]">
上传底图后,系统会按管理员配置好的多个模式同时生成洗图结果。
</div>
)}
</div>
</div>
)}
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs text-white/62">
<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},可直接发送回画布`
: "请选择或生成一张洗图结果"}
</div>
<Button
className="h-11 rounded-[18px] border border-white/12 bg-white text-[#111111] px-5 hover:bg-white/90"
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" />
发送到画布
@ -1326,19 +1368,19 @@ export function AiWashWorkspace({
<div className={cn(PANEL_CLASS, "mt-5 p-4")}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-[16px] border border-white/10 bg-white/[0.05]">
<HistoryIcon className="h-5 w-5 text-white/80" />
<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 tracking-[-0.03em]">
<div className="text-lg font-semibold text-[#111111]">
历史洗图记录
</div>
<div className="text-sm text-white/55">
<div className="text-sm text-[#666666]">
保留最近的 AI洗图批次,支持一键回看并继续发送结果到画布。
</div>
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs text-white/65">
<div className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
最近 {visibleHistoryBatches.length} 条
</div>
</div>
@ -1349,7 +1391,7 @@ export function AiWashWorkspace({
const cover = entry.items[0];
return (
<div
className="min-w-[260px] max-w-[260px] rounded-[22px] border border-white/10 bg-white/[0.03] p-3"
className="min-w-[260px] max-w-[260px] rounded-[22px] border border-[#ececec] bg-[#fbfbfb] p-3"
key={entry.id}
>
<button
@ -1357,7 +1399,7 @@ export function AiWashWorkspace({
onClick={() => handleLoadHistoryBatch(entry)}
type="button"
>
<div className="overflow-hidden rounded-[18px] border border-white/10 bg-black/20">
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
<img
alt={cover?.title || "历史洗图结果"}
className="aspect-[4/3] w-full object-cover"
@ -1369,10 +1411,10 @@ export function AiWashWorkspace({
</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-white">
<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-white/52">
<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",
@ -1382,7 +1424,7 @@ export function AiWashWorkspace({
})}
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-1 text-[11px] text-white/72">
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{entry.items.length} 张
</div>
</div>
@ -1390,18 +1432,18 @@ export function AiWashWorkspace({
<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-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-white/60">
<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-emerald-400/20 bg-emerald-400/10 px-2 py-1 text-[11px] text-emerald-100">
<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-white/10 bg-white/[0.04] text-white/70 transition hover:bg-white/[0.1] hover:text-white"
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);
@ -1417,7 +1459,7 @@ export function AiWashWorkspace({
})}
</div>
) : (
<div className="rounded-[22px] border border-dashed border-white/10 bg-white/[0.02] px-5 py-8 text-center text-sm text-white/48">
<div className="rounded-[22px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)}