feat(intake): 重写 AI 白底图工作区界面

This commit is contained in:
WuYongJie 2026-06-15 20:10:25 +08:00
parent 33c2e2899f
commit 562cf5bba3

View File

@ -1,15 +1,24 @@
"use client";
import { useState } from "react";
import { AlertCircle, ImageIcon, Sparkles, UploadCloud, X } from "lucide-react";
import {
AlertCircle,
CheckCircle2,
ImageIcon,
Maximize2,
RefreshCcw,
Sparkles,
UploadCloud,
X,
} from "lucide-react";
import Button from "@/components/ui/Button";
import Select from "@/components/ui/Select";
import { cn } from "@/lib/utils";
import {
AI_WHITE_BACKGROUND_VIEW_KEYS,
AI_WHITE_BACKGROUND_VIEW_LABELS,
type AiWhiteBackgroundTargetFinishOption,
type AiWhiteBackgroundSessionStatus,
type AiWhiteBackgroundTargetFinishOption,
type AiWhiteBackgroundUploadManifest,
type AiWhiteBackgroundViewKey,
} from "./nonIntegratedAiWhiteBackground";
@ -74,31 +83,36 @@ function getSessionStatusMeta(status: AiWhiteBackgroundSessionStatus | null) {
if (status === "uploaded") {
return {
label: "已上传",
className: "bg-[#eef7f2] text-[#2f6b51]",
className: "bg-[#e6f3fb] text-[#39565d]",
dotClassName: "bg-[#47646b]",
};
}
if (status === "generated") {
return {
label: "已生成",
className: "bg-black/[0.035] text-apple-label-primary",
className: "bg-[#eef7f2] text-[#2f6b51]",
dotClassName: "bg-[#2f6b51]",
};
}
if (status === "upload_failed") {
return {
label: "上传失败",
className: "bg-[#fff3f5] text-[#a5485a]",
className: "bg-[#fff1ef] text-[#9a403d]",
dotClassName: "bg-[#a83836]",
};
}
if (status === "draft") {
return {
label: "待生成",
className: "bg-black/[0.035] text-apple-label-secondary",
className: "bg-[#f1f4f5] text-[#5a6062]",
dotClassName: "bg-[#8a9496]",
};
}
return {
label: "未开始",
className: "bg-black/[0.035] text-apple-label-secondary",
className: "bg-[#f1f4f5] text-[#5a6062]",
dotClassName: "bg-[#adb3b5]",
};
}
@ -106,7 +120,8 @@ function getManifestStatusMeta(manifest: AiWhiteBackgroundUploadManifest | null)
if (!manifest) {
return {
label: "未上传素材库",
className: "bg-black/[0.035] text-apple-label-secondary",
className: "bg-[#f1f4f5] text-[#5a6062]",
dotClassName: "bg-[#adb3b5]",
};
}
@ -114,30 +129,35 @@ function getManifestStatusMeta(manifest: AiWhiteBackgroundUploadManifest | null)
return {
label: "素材库生效中",
className: "bg-[#eef7f2] text-[#2f6b51]",
dotClassName: "bg-[#2f6b51]",
};
}
if (manifest.status === "cleanup_pending") {
return {
label: "清理中",
className: "bg-[#fff7e8] text-[#8a6538]",
className: "bg-[#fff7e8] text-[#725737]",
dotClassName: "bg-[#b6905d]",
};
}
if (manifest.status === "cleaned") {
return {
label: "已清理",
className: "bg-black/[0.035] text-apple-label-secondary",
className: "bg-[#f1f4f5] text-[#5a6062]",
dotClassName: "bg-[#8a9496]",
};
}
if (manifest.status === "cleanup_failed") {
return {
label: "清理失败",
className: "bg-[#fff3f5] text-[#a5485a]",
className: "bg-[#fff1ef] text-[#9a403d]",
dotClassName: "bg-[#a83836]",
};
}
return {
label: "待人工复核",
className: "bg-[#fff7e8] text-[#8a6538]",
className: "bg-[#fff7e8] text-[#725737]",
dotClassName: "bg-[#b6905d]",
};
}
@ -156,6 +176,62 @@ function getTargetFinishLabel(finish: AiWhiteBackgroundTargetFinishOption) {
.join(" · ");
}
function StatChip({
label,
value,
tone = "neutral",
}: {
label: string;
value: string;
tone?: "neutral" | "success" | "warning";
}) {
return (
<div className="grid min-w-[86px] gap-0.5 rounded-[6px] bg-white/72 px-3 py-2 shadow-[0_1px_0_rgba(173,179,181,0.18)] ring-1 ring-[#adb3b5]/15">
<span className="sr-only">
{label} {value}
</span>
<span className="text-[10px] font-black uppercase tracking-[0.16em] text-[#5a6062]/64">
{label}
</span>
<span
className={cn(
"font-mono text-[15px] font-black leading-5",
tone === "success" && "text-[#2f6b51]",
tone === "warning" && "text-[#725737]",
tone === "neutral" && "text-[#2d3335]",
)}
>
{value}
</span>
</div>
);
}
function StatusPill({
label,
value,
className,
dotClassName,
}: {
label: string;
value: string;
className: string;
dotClassName: string;
}) {
return (
<span
className={cn(
"inline-flex h-7 items-center gap-2 rounded-[6px] px-2.5 text-[11px] font-bold",
className,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", dotClassName)} />
<span className="text-[#5a6062]/72">{label}</span>
<span className="text-current">{value}</span>
</span>
);
}
function AiWhiteBackgroundImagePreviewDialog({
src,
alt,
@ -167,29 +243,34 @@ function AiWhiteBackgroundImagePreviewDialog({
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 p-4"
className="fixed inset-0 z-[70] flex items-center justify-center bg-[#0c0f10]/72 p-4 backdrop-blur-[10px]"
role="dialog"
aria-label={alt}
onClick={onClose}
>
<div
className="relative flex max-h-[88vh] w-full max-w-5xl flex-col rounded-[18px] bg-white p-4 shadow-[0_28px_80px_rgba(15,23,42,0.32)]"
className="relative flex max-h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-[8px] bg-[#f8f9fa] shadow-[0_24px_72px_rgba(12,15,16,0.35)] ring-1 ring-white/18"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="min-w-0 truncate text-sm font-apple-medium text-[#2d3335]">
{alt}
</p>
<div className="flex h-12 items-center justify-between gap-3 bg-white/82 px-4">
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/62">
Source Preview
</p>
<p className="truncate text-[13px] font-black text-[#2d3335]">
{alt}
</p>
</div>
<button
type="button"
aria-label="关闭预览"
onClick={onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black/[0.04] text-[#5a6062] transition hover:bg-black/[0.08] hover:text-[#2d3335]"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-[#ebeef0] text-[#5a6062] transition hover:bg-[#dee3e6] hover:text-[#2d3335]"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-hidden rounded-[14px] bg-[#f5f6f7]">
<div className="m-3 overflow-hidden rounded-[7px] bg-[#ebeef0]">
{src.trim() ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
@ -200,7 +281,7 @@ function AiWhiteBackgroundImagePreviewDialog({
/>
</>
) : (
<div className="flex min-h-[320px] items-center justify-center px-6 text-sm text-[#5a6062]">
<div className="flex min-h-[320px] items-center justify-center px-6 text-sm font-semibold text-[#5a6062]">
原图加载失败
</div>
)}
@ -221,7 +302,7 @@ function AiWhiteBackgroundCardImage({
}) {
if (!file.dataUrl.trim()) {
return (
<div className="flex h-full w-full items-center justify-center px-4 text-[11px] text-[#5a6062]/72">
<div className="flex h-full w-full items-center justify-center px-4 text-[11px] font-semibold text-[#5a6062]/72">
源图加载失败
</div>
);
@ -304,153 +385,148 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
: null;
return (
<div className="flex h-full min-h-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-black/[0.035] px-2.5 py-1 text-[11px] font-medium text-[#2d3335]">
{styleLabel}
</span>
<span className="rounded-full bg-black/[0.035] px-2.5 py-1 font-mono text-[11px] font-semibold text-[#5a6062]">
{styleKey}
</span>
<span
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium",
sessionMeta.className,
)}
>
会话:{sessionMeta.label}
</span>
<span
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium",
manifestMeta.className,
)}
>
素材库:{manifestMeta.label}
</span>
<span className="rounded-full bg-black/[0.035] px-2.5 py-1 text-[11px] text-[#5a6062]">
可生成 {availableSourceViewCount}/8
</span>
<span className="rounded-full bg-black/[0.035] px-2.5 py-1 text-[11px] text-[#5a6062]">
已生成 {generatedCount}/8
</span>
<span className="rounded-full bg-black/[0.035] px-2.5 py-1 text-[11px] text-[#5a6062]">
已上传 {uploadedCount}/8
</span>
</div>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#f8f9fa] text-[#2d3335]">
<header className="shrink-0 bg-[#f1f4f5] px-3 py-3 sm:px-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-[220px] flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-[5px] bg-[#47646b] px-2 py-1 text-[10px] font-black uppercase tracking-[0.16em] text-[#eefbff]">
AI White Studio
</span>
<StatusPill
label="会话"
value={sessionMeta.label}
className={sessionMeta.className}
dotClassName={sessionMeta.dotClassName}
/>
<StatusPill
label="素材库"
value={manifestMeta.label}
className={manifestMeta.className}
dotClassName={manifestMeta.dotClassName}
/>
</div>
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-[18px] font-black leading-6 tracking-[-0.01em] text-[#2d3335]">
AI 白底图工坊
</h2>
<span className="text-[12px] font-bold text-[#5a6062]">
{styleLabel}
</span>
<span className="rounded-[4px] bg-white/72 px-2 py-0.5 font-mono text-[11px] font-black text-[#5a6062] ring-1 ring-[#adb3b5]/15">
{styleKey}
</span>
</div>
</div>
<div
data-testid="ai-white-background-grid"
className="grid min-h-[220px] flex-1 grid-cols-3 grid-rows-3 gap-2"
>
{AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => {
const generatedFile = generatedFiles[viewKey];
const sourceFile = sourceFilesByView[viewKey];
const sourceReady = sourceReadyByView[viewKey];
const displayedImageFile = isImageUploadFile(generatedFile)
? generatedFile
: isImageUploadFile(sourceFile)
? sourceFile
: null;
const isShowingGeneratedFile =
generatedFile != null && displayedImageFile?.id === generatedFile.id;
const previewLabel = isShowingGeneratedFile
? `${AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}原图`
: `${AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}源图`;
const hoverPreviewLabel = isShowingGeneratedFile
? "点击查看原图"
: "点击查看源图";
const cardStatusLabel = generatedFile
? "已生成"
: sourceReady
? "可生成"
: "缺源图";
const isViewGenerating = generatingViewKeySet.has(viewKey);
<div className="flex flex-wrap gap-2">
<StatChip
label="可生成"
value={`${availableSourceViewCount}/8`}
tone={availableSourceViewCount > 0 ? "success" : "warning"}
/>
<StatChip
label="已生成"
value={`${generatedCount}/8`}
tone={generatedCount > 0 ? "success" : "neutral"}
/>
<StatChip
label="已上传"
value={`${uploadedCount}/8`}
tone={uploadedCount > 0 ? "success" : "neutral"}
/>
</div>
</div>
</header>
return (
<article
key={viewKey}
className={cn(
"group flex min-h-0 h-full flex-col rounded-[16px] p-2.5 transition shadow-[0_8px_18px_rgba(20,24,28,0.03)]",
generatedFile
? "bg-white"
: sourceReady
? "bg-white/82"
: "bg-black/[0.02]",
)}
>
<div className="flex items-start justify-between gap-3">
<p className="truncate text-[12px] font-apple-semibold text-[#2d3335]">
{AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}
</p>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
generatedFile
? "bg-[#eef7f2] text-[#2f6b51]"
: sourceReady
? "bg-black/[0.035] text-apple-label-secondary"
: "bg-white/72 text-apple-label-tertiary",
)}
>
{cardStatusLabel}
</span>
</div>
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden p-3 xl:grid-cols-[minmax(0,1fr)_336px]">
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[8px] bg-[#f1f4f5] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.62)]">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/64">
Generated Views
</p>
<p className="truncate text-[13px] font-black text-[#2d3335]">
九视图生成面板
</p>
</div>
<span className="rounded-[5px] bg-white/72 px-2.5 py-1 font-mono text-[11px] font-black text-[#5a6062] ring-1 ring-[#adb3b5]/15">
{generatedCount}/8
</span>
</div>
<div
<div
data-testid="ai-white-background-grid"
className="grid min-h-0 flex-1 auto-rows-[minmax(168px,1fr)] grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4"
>
{AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => {
const generatedFile = generatedFiles[viewKey];
const sourceFile = sourceFilesByView[viewKey];
const sourceReady = sourceReadyByView[viewKey];
const displayedImageFile = isImageUploadFile(generatedFile)
? generatedFile
: isImageUploadFile(sourceFile)
? sourceFile
: null;
const isShowingGeneratedFile =
generatedFile != null && displayedImageFile?.id === generatedFile.id;
const previewLabel = isShowingGeneratedFile
? `${AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}原图`
: `${AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}源图`;
const hoverPreviewLabel = isShowingGeneratedFile
? "点击查看原图"
: "点击查看源图";
const cardStatusLabel = generatedFile
? "已生成"
: sourceReady
? "可生成"
: "缺源图";
const isViewGenerating = generatingViewKeySet.has(viewKey);
return (
<article
key={viewKey}
className={cn(
"mt-2 flex aspect-[5/4] min-h-0 items-center justify-center overflow-hidden rounded-[12px]",
displayedImageFile
? "bg-white"
: "bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.96),rgba(243,245,246,0.9))]",
"group flex min-h-0 flex-col rounded-[7px] bg-white/92 p-2.5 shadow-[0_4px_20px_rgba(45,51,53,0.055)] ring-1 ring-[#adb3b5]/15 transition",
generatedFile && "bg-white",
!generatedFile && sourceReady && "bg-[#ffffff]/86",
!generatedFile && !sourceReady && "bg-[#ebeef0]/72",
)}
>
{displayedImageFile ? (
<button
type="button"
className="relative h-full w-full cursor-zoom-in overflow-hidden rounded-[12px] text-left"
aria-label={`查看${previewLabel}`}
onClick={() =>
setPreviewImage({
src: displayedImageFile.dataUrl,
alt: previewLabel,
})
}
>
<AiWhiteBackgroundCardImage
file={displayedImageFile}
alt={displayedImageFile.name}
className="h-full w-full object-contain transition duration-200 group-hover:scale-[1.01]"
/>
{isShowingGeneratedFile ? null : (
<span className="pointer-events-none absolute left-3 top-3 rounded-full bg-[rgba(255,255,255,0.92)] px-2 py-0.5 text-[10px] font-medium text-[#725737] shadow-[0_6px_16px_rgba(20,24,28,0.06)]">
源图
</span>
)}
<span className="pointer-events-none absolute inset-x-3 bottom-3 rounded-full bg-white/88 px-2.5 py-1 text-center text-[10px] font-medium text-[#2d3335] opacity-0 shadow-[0_8px_18px_rgba(20,24,28,0.08)] transition group-hover:opacity-100">
{hoverPreviewLabel}
</span>
</button>
) : (
<div className="flex items-center justify-center px-4">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/92 ring-1 ring-black/[0.05]">
{sourceReady ? (
<Sparkles className="h-4.5 w-4.5 text-[#5a6062]/62" />
) : (
<ImageIcon className="h-4.5 w-4.5 text-[#5a6062]/42" />
)}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[12px] font-black text-[#2d3335]">
{AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}
</p>
<p className="mt-0.5 text-[10px] font-semibold text-[#5a6062]/58">
{isShowingGeneratedFile ? "Generated output" : "Source reference"}
</p>
</div>
)}
</div>
<span
className={cn(
"inline-flex h-5 shrink-0 items-center rounded-[4px] px-1.5 text-[10px] font-bold",
generatedFile && "bg-[#eef7f2] text-[#2f6b51]",
!generatedFile && sourceReady && "bg-[#e6f3fb] text-[#39565d]",
!generatedFile && !sourceReady && "bg-[#f1f4f5] text-[#8a9496]",
)}
>
{cardStatusLabel}
</span>
</div>
{displayedImageFile ? (
<div className="mt-2 flex min-h-[28px] items-start justify-between gap-2 text-[10px] leading-4 text-[#5a6062]/45">
<span className="line-clamp-2 flex-1">{displayedImageFile.name}</span>
{isShowingGeneratedFile ? (
<div
className={cn(
"mt-2 flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-[6px]",
displayedImageFile
? "bg-[#f8f9fa]"
: "bg-[linear-gradient(135deg,#ffffff_0%,#ebeef0_100%)]",
)}
>
{displayedImageFile ? (
<button
type="button"
className="shrink-0 rounded-full bg-black/[0.035] px-2 py-0.5 text-[10px] text-[#2d3335] transition hover:bg-black/[0.06]"
className="relative h-full min-h-[106px] w-full cursor-zoom-in overflow-hidden rounded-[6px] text-left"
aria-label={`查看${previewLabel}`}
onClick={() =>
setPreviewImage({
src: displayedImageFile.dataUrl,
@ -458,182 +534,255 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
})
}
>
查看原图
<AiWhiteBackgroundCardImage
file={displayedImageFile}
alt={displayedImageFile.name}
className="h-full w-full object-contain transition duration-200 group-hover:scale-[1.012]"
/>
<span className="pointer-events-none absolute left-2 top-2 inline-flex h-6 items-center gap-1 rounded-[5px] bg-white/88 px-2 text-[10px] font-black text-[#2d3335] shadow-[0_4px_12px_rgba(45,51,53,0.08)]">
{isShowingGeneratedFile ? (
<CheckCircle2 className="h-3 w-3 text-[#2f6b51]" />
) : (
<ImageIcon className="h-3 w-3 text-[#725737]" />
)}
{isShowingGeneratedFile ? "生成图" : "源图"}
</span>
<span className="pointer-events-none absolute inset-x-2 bottom-2 inline-flex h-7 items-center justify-center gap-1 rounded-[5px] bg-[#0c0f10]/72 px-2 text-[10px] font-bold text-white opacity-0 backdrop-blur transition group-hover:opacity-100">
<Maximize2 className="h-3 w-3" />
{hoverPreviewLabel}
</span>
</button>
) : (
<span className="shrink-0 rounded-full bg-[#f5efe5] px-2 py-0.5 text-[10px] text-[#7b6241]">
源图
</span>
<div className="flex min-h-[106px] items-center justify-center px-4">
<div className="grid place-items-center gap-2 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-[6px] bg-white/78 text-[#5a6062] shadow-[0_4px_16px_rgba(45,51,53,0.05)] ring-1 ring-[#adb3b5]/15">
{sourceReady ? (
<Sparkles className="h-4.5 w-4.5" />
) : (
<ImageIcon className="h-4.5 w-4.5 opacity-60" />
)}
</div>
<p className="text-[10px] font-semibold text-[#5a6062]/62">
{sourceReady ? "等待生成" : "等待补源图"}
</p>
</div>
</div>
)}
</div>
) : (
<div className="mt-2 min-h-[28px]" />
)}
<Button
type="button"
size="small"
variant={generatedFile ? "gray" : "filled"}
className="mt-2 h-[30px] w-full rounded-[10px] text-[11px]"
loading={isViewGenerating}
disabled={
!canOperateAiWhiteBackground ||
!selectedBarcode ||
!sourceReady ||
targetFinishTextureMissing ||
isBulkGenerating ||
isViewGenerating
}
onClick={() => void onGenerate([viewKey])}
>
{generatedFile ? "重生成" : sourceReady ? "生成" : "待补源图"}
</Button>
</article>
);
})}
<div className="mt-2 min-h-[34px]">
{displayedImageFile ? (
<div className="flex items-start justify-between gap-2 text-[10px] leading-4 text-[#5a6062]/58">
<span className="line-clamp-2 min-w-0 flex-1 font-semibold">
{displayedImageFile.name}
</span>
{isShowingGeneratedFile ? (
<button
type="button"
className="shrink-0 rounded-[4px] bg-[#f1f4f5] px-2 py-0.5 text-[10px] font-black text-[#2d3335] transition hover:bg-[#e5e9eb]"
onClick={() =>
setPreviewImage({
src: displayedImageFile.dataUrl,
alt: previewLabel,
})
}
>
原图
</button>
) : (
<span className="shrink-0 rounded-[4px] bg-[#fff7e8] px-2 py-0.5 text-[10px] font-black text-[#725737]">
源图
</span>
)}
</div>
) : null}
</div>
<Button
type="button"
size="small"
variant={generatedFile ? "gray" : "filled"}
className="mt-2 h-8 w-full !min-h-8 rounded-[6px] px-2 text-[11px] font-black"
loading={isViewGenerating}
disabled={
!canOperateAiWhiteBackground ||
!selectedBarcode ||
!sourceReady ||
targetFinishTextureMissing ||
isBulkGenerating ||
isViewGenerating
}
onClick={() => void onGenerate([viewKey])}
>
{generatedFile ? (
<>
<RefreshCcw className="h-3.5 w-3.5" />
重新生成
</>
) : sourceReady ? (
<>
<Sparkles className="h-3.5 w-3.5" />
生成
</>
) : (
"待补源图"
)}
</Button>
</article>
);
})}
</div>
</section>
<aside
data-testid="ai-white-background-control-card"
className="flex min-h-0 h-full flex-col rounded-[16px] bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(245,247,248,0.92))] p-3 shadow-[0_8px_18px_rgba(20,24,28,0.03)]"
className="flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-white/92 shadow-[0_4px_20px_rgba(45,51,53,0.06)] ring-1 ring-[#adb3b5]/15"
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[12px] font-apple-semibold text-[#2d3335]">
<div className="border-b border-[#adb3b5]/15 bg-[#f8f9fa] px-4 py-3">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/64">
Inspector
</p>
<div className="mt-1 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[14px] font-black text-[#2d3335]">
AI 白底图
</p>
<p className="mt-1 text-[10px] leading-4 text-[#5a6062]/72">
<p className="mt-1 text-[11px] font-semibold leading-4 text-[#5a6062]/72">
{statusText}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
"inline-flex h-6 shrink-0 items-center rounded-[5px] px-2 font-mono text-[11px] font-black",
manifestMeta.className,
)}
>
{uploadedCount}/8
</span>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-[10px] font-black uppercase tracking-[0.16em] text-[#5a6062]/45">
目标条码
</label>
<select
data-testid="ai-white-background-barcode"
value={selectedBarcode}
onChange={(event) =>
onSelectedBarcodeChange(event.target.value.trim().toUpperCase())
}
disabled={
!canOperateAiWhiteBackground ||
barcodeOptions.length === 0
}
className="h-9 w-full rounded-[10px] bg-white/88 px-3 text-sm text-[#2d3335] outline-none ring-1 ring-black/[0.05] transition focus:ring-black/[0.12] disabled:cursor-not-allowed disabled:text-[#5a6062]/35"
>
{barcodeOptions.length === 0 ? (
<option value="">当前没有可用条码</option>
) : null}
{barcodeOptions.map((option) => (
<option key={option.barcode} value={option.barcode}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="flex items-end gap-2">
<Select
label="目标花色"
searchable
searchPlaceholder="搜索花色名称、分类或编码"
emptyText="基础资料库暂无匹配花色"
placeholder={
defaultTargetColor.trim()
? `不改色(默认:${defaultTargetColor.trim()})`
: "不改色 / 选择目标花色"
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<div className="grid gap-3">
<div className="space-y-1.5">
<label className="block text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/62">
目标条码
</label>
<select
data-testid="ai-white-background-barcode"
value={selectedBarcode}
onChange={(event) =>
onSelectedBarcodeChange(event.target.value.trim().toUpperCase())
}
value={targetFinishId}
onChange={(event) => onTargetFinishChange(event.target.value)}
disabled={
!canOperateAiWhiteBackground ||
hasAnyGenerationInFlight ||
targetFinishOptions.length === 0
barcodeOptions.length === 0
}
options={targetFinishOptions.map((finish) => ({
value: finish.id,
label: getTargetFinishLabel(finish),
}))}
size="small"
tone="obsidian"
containerClassName="min-w-0 flex-1 space-y-1.5"
className="h-9 min-h-9 rounded-[10px] bg-white/88 px-3 text-sm text-[#2d3335] ring-1 ring-black/[0.05] hover:bg-white focus:ring-black/[0.12] disabled:cursor-not-allowed"
/>
<span
aria-hidden="true"
className="mb-0 h-9 w-9 shrink-0 overflow-hidden rounded-[10px] bg-[#f3f4f6] ring-1 ring-black/[0.06]"
style={{
backgroundColor:
!selectedTargetFinishPreviewUrl &&
isHexColor(selectedTargetFinish?.color ?? effectiveTargetColor)
? (selectedTargetFinish?.color ?? effectiveTargetColor)
: "#f3f4f6",
}}
className="h-10 w-full rounded-[6px] bg-[#f1f4f5] px-3 text-[13px] font-semibold text-[#2d3335] outline-none ring-1 ring-[#adb3b5]/20 transition focus:bg-white focus:ring-[#47646b]/40 disabled:cursor-not-allowed disabled:text-[#5a6062]/35"
>
{selectedTargetFinishPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selectedTargetFinishPreviewUrl}
alt=""
className="h-full w-full object-cover"
/>
{barcodeOptions.length === 0 ? (
<option value="">当前没有可用条码</option>
) : null}
</span>
{barcodeOptions.map((option) => (
<option key={option.barcode} value={option.barcode}>
{option.label}
</option>
))}
</select>
</div>
{targetFinishDisabledReason ? (
<p className="text-[10px] leading-4 text-[#9a6a32]">
{targetFinishDisabledReason}
</p>
<div className="space-y-1.5">
<div className="flex items-end gap-2">
<Select
label="目标花色"
searchable
searchPlaceholder="搜索花色名称、分类或编码"
emptyText="基础资料库暂无匹配花色"
placeholder={
defaultTargetColor.trim()
? `不改色(默认:${defaultTargetColor.trim()})`
: "不改色 / 选择目标花色"
}
value={targetFinishId}
onChange={(event) => onTargetFinishChange(event.target.value)}
disabled={
!canOperateAiWhiteBackground ||
hasAnyGenerationInFlight ||
targetFinishOptions.length === 0
}
options={targetFinishOptions.map((finish) => ({
value: finish.id,
label: getTargetFinishLabel(finish),
}))}
size="small"
tone="obsidian"
containerClassName="min-w-0 flex-1 space-y-1.5 [&>label]:mb-0 [&>label]:text-[10px] [&>label]:font-black [&>label]:uppercase [&>label]:tracking-[0.18em] [&>label]:text-[#5a6062]/62"
className="h-10 min-h-10 rounded-[6px] bg-[#f1f4f5] px-3 text-[13px] font-semibold text-[#2d3335] ring-1 ring-[#adb3b5]/20 hover:bg-[#ebeef0] focus:ring-[#47646b]/40 disabled:cursor-not-allowed"
/>
<span
aria-hidden="true"
className="mb-0 h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#dee3e6] ring-1 ring-[#adb3b5]/22"
style={{
backgroundColor:
!selectedTargetFinishPreviewUrl &&
isHexColor(selectedTargetFinish?.color ?? effectiveTargetColor)
? (selectedTargetFinish?.color ?? effectiveTargetColor)
: "#dee3e6",
}}
>
{selectedTargetFinishPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selectedTargetFinishPreviewUrl}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</span>
</div>
{targetFinishDisabledReason ? (
<p className="text-[11px] font-semibold leading-4 text-[#725737]">
{targetFinishDisabledReason}
</p>
) : null}
</div>
{operationDisabledReason ? (
<div className="rounded-[6px] bg-[#f1f4f5] px-3 py-2 text-[11px] font-semibold leading-4 text-[#5a6062] ring-1 ring-[#adb3b5]/15">
{operationDisabledReason}
</div>
) : null}
</div>
{operationDisabledReason ? (
<div className="rounded-[12px] border border-[#e0e4e5] bg-white/78 px-3 py-2 text-[10px] leading-4 text-[#5a6062]">
{operationDisabledReason}
</div>
) : null}
{missingSourceSlotLabels.length > 0 ? (
<div className="rounded-[12px] bg-[#f7efe2] px-2.5 py-2 text-[#7c6748]">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#b6905d]" />
<div className="min-w-0">
<p className="text-[10px] font-medium">待补角度</p>
<div className="mt-1 flex flex-wrap gap-1">
{missingSourceSlotLabels.map((label) => (
<span
key={label}
className="rounded-full bg-white/72 px-1.5 py-0.5 text-[10px] leading-4 text-[#87653f]"
>
{label}
</span>
))}
{missingSourceSlotLabels.length > 0 ? (
<div className="rounded-[6px] bg-[#fff7e8] px-3 py-2 text-[#725737] ring-1 ring-[#b6905d]/18">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#b6905d]" />
<div className="min-w-0">
<p className="text-[11px] font-black">待补角度</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{missingSourceSlotLabels.map((label) => (
<span
key={label}
className="rounded-[4px] bg-white/74 px-1.5 py-0.5 text-[10px] font-bold leading-4 text-[#725737]"
>
{label}
</span>
))}
</div>
</div>
</div>
</div>
</div>
) : null}
) : null}
{!canUploadToMaterialLibrary && uploadDisabledReason ? (
<div className="rounded-[12px] border border-[#f1d7c1] bg-[#fff8f2] px-3 py-2 text-[10px] leading-4 text-[#8a6538]">
{uploadDisabledReason}
</div>
) : null}
{!canUploadToMaterialLibrary && uploadDisabledReason ? (
<div className="rounded-[6px] bg-[#fff7e8] px-3 py-2 text-[11px] font-semibold leading-4 text-[#725737] ring-1 ring-[#b6905d]/18">
{uploadDisabledReason}
</div>
) : null}
</div>
</div>
<div className="mt-auto grid gap-2 pt-3">
<div className="grid shrink-0 gap-2 border-t border-[#adb3b5]/15 bg-[#f8f9fa] px-4 py-3">
<Button
type="button"
size="small"
@ -645,7 +794,7 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
targetFinishTextureMissing ||
hasAnyGenerationInFlight
}
className="h-9 rounded-[10px] text-[11px]"
className="h-10 !min-h-10 rounded-[6px] text-[12px] font-black"
onClick={() => void onGenerate()}
>
<Sparkles className="h-3.5 w-3.5" />
@ -663,7 +812,7 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
!canUploadToMaterialLibrary ||
isUploading
}
className="h-9 rounded-[10px] text-[11px]"
className="h-10 !min-h-10 rounded-[6px] text-[12px] font-black"
onClick={() => void onUpload()}
>
<UploadCloud className="h-3.5 w-3.5" />
@ -683,7 +832,7 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
currentManifest.status === "cleanup_pending" ||
cleaningManifestId === currentManifest.manifestId
}
className="h-8 rounded-[10px] text-[11px]"
className="h-9 !min-h-9 rounded-[6px] text-[11px] font-black"
onClick={() => void onCleanup(currentManifest)}
>
清理当前上传清单
@ -692,6 +841,7 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
</div>
</aside>
</div>
{previewImage ? (
<AiWhiteBackgroundImagePreviewDialog
src={previewImage.src}