fix(ai-white-background): 无花色借源颜色污染修复 + 工作区重排

根因:无花色 SKU 借源生图时,"未配置花色"占位符被当真实色传入,
后端 normalize 后 prompt 退化为"保持源图原色",AI 跟随源条码原图色调
生成错误颜色(SZ024203V01 案例:借胡桃色源图生成出胡桃色结果)。

A 方案(解开死结):
- Workspace 新增 inlineTargetColor 状态,用户可手动指定本次生图颜色
- isEffectiveAiWhiteBackgroundTargetColor 识别"未配置花色"等占位符为无效色
- targetColor 改为 effectiveTargetColor,占位符不再绕过空值校验
- Panel 目标花色区改为条件可输入:SKU 无色时显示输入框

B 方案(借源必填):
- 借源模式 + 无色时给出明确警示"必须指定目标颜色,否则跟随源条码色调"
- 输入框用警示色高亮

工作区重排(解决"操作小小一块"):
- 设置区加宽 310px→380px,字号放大 10/12px→11/13px
- 视图卡片放大 164/190px→180/210px,间距 gap-2→gap-3
- "目标与源图"小节默认展开(最常用不该藏)

验证:type-check 通过,白底图 61 个测试全过(含新增 3 个 A/B 方案验证)
This commit is contained in:
WuYongJie 2026-06-25 15:56:02 +08:00
parent c172ce87bc
commit 8cc0dc5218
3 changed files with 187 additions and 21 deletions

View File

@ -76,6 +76,9 @@ describe("NonIntegratedAiWhiteBackgroundPanel", () => {
targetBarcode="BAR-001"
targetLabel="BAR-001 / 批次1"
targetColor="胡桃木色"
skuTargetColor="胡桃木色"
inlineTargetColor=""
onInlineTargetColorChange={() => {}}
targetHasOriginalImages
sourceMode="self"
selectedSourceBarcode=""
@ -349,6 +352,9 @@ describe("NonIntegratedAiWhiteBackgroundPanel", () => {
targetBarcode="BAR-002"
targetLabel="BAR-002 / 批次1"
targetColor="浅橡木色"
skuTargetColor="浅橡木色"
inlineTargetColor=""
onInlineTargetColorChange={() => {}}
targetHasOriginalImages={false}
sourceMode="borrow"
selectedSourceBarcode="BAR-001"
@ -404,4 +410,79 @@ describe("NonIntegratedAiWhiteBackgroundPanel", () => {
expect(screen.queryByText("BAR-001 · 胡桃木色")).not.toBeInTheDocument();
expect(screen.queryByTitle("测试产品一")).not.toBeInTheDocument();
});
it("renders a color input when the SKU has no real finish (A 方案)", () => {
const onInlineTargetColorChange = jest.fn();
renderPanel({
targetColor: "",
skuTargetColor: "未配置花色",
inlineTargetColor: "",
onInlineTargetColorChange,
});
fireEvent.click(screen.getByTestId("ai-white-background-settings-source"));
const input = screen.getByTestId(
"ai-white-background-inline-target-color",
);
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute(
"placeholder",
expect.stringContaining("本次生图颜色"),
);
expect(
screen.getByText(/该条码未配置花色|借源生图必须指定目标颜色/),
).toBeInTheDocument();
});
it("shows the borrow-source color warning when borrowing without a target color (B 方案)", () => {
renderPanel({
targetColor: "",
skuTargetColor: "未配置花色",
inlineTargetColor: "",
sourceMode: "borrow",
targetHasOriginalImages: false,
selectedSourceBarcode: "BAR-001",
sourceBarcodeOptions: [
{
key: "BAR-001",
skuItemId: "sku-1",
barcode: "BAR-001",
label: "测试产品一",
secondaryLabel: "胡桃木色",
batchNo: 1,
hasOriginalImages: true,
generatedWhiteBackgroundCount: 3,
productPhotoSlotFiles: {} as never,
aiSourceSlotFiles: {} as never,
availableProductPhotoSlotCount: 6,
availableAiSourceSlotCount: 6,
files: [],
},
],
availableSourceViewCount: 1,
sourceReadyByView: buildViewRecord((viewKey) => viewKey === "front"),
});
fireEvent.click(screen.getByTestId("ai-white-background-settings-source"));
expect(
screen.getByText(/借源生图必须指定目标颜色/),
).toBeInTheDocument();
});
it("keeps the readonly finish field when the SKU has a real color", () => {
renderPanel({
targetColor: "胡桃木色",
skuTargetColor: "胡桃木色",
inlineTargetColor: "",
});
fireEvent.click(screen.getByTestId("ai-white-background-settings-source"));
expect(
screen.queryByTestId("ai-white-background-inline-target-color"),
).not.toBeInTheDocument();
expect(screen.getByText("胡桃木色")).toBeInTheDocument();
});
});

View File

@ -45,6 +45,9 @@ type NonIntegratedAiWhiteBackgroundPanelProps = {
targetBarcode: string;
targetLabel: string;
targetColor: string;
skuTargetColor: string;
inlineTargetColor: string;
onInlineTargetColorChange: (value: string) => void;
targetHasOriginalImages: boolean;
sourceMode: "self" | "borrow";
selectedSourceBarcode: string;
@ -727,6 +730,9 @@ function AiWhiteBackgroundPartGroupEditor({
export default function NonIntegratedAiWhiteBackgroundPanel({
targetColor,
skuTargetColor,
inlineTargetColor,
onInlineTargetColorChange,
sourceMode,
selectedSourceBarcode,
sourceBarcodeOptions,
@ -769,6 +775,19 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
useState<AiWhiteBackgroundViewKey | null>(null);
const [refinementPrompt, setRefinementPrompt] = useState("");
// 花色来源判断:SKU 自带真实色 vs 占位符 vs 用户手动输入
const trimmedSkuTargetColor = (skuTargetColor ?? "").trim();
const skuHasRealColor = (() => {
if (!trimmedSkuTargetColor) return false;
const compact = trimmedSkuTargetColor.replace(/\s+/g, "");
return !["未配置花色", "未配置颜色", "缺少花色/颜色信息", "不改色/选择目标花色"]
.some((p) => p.replace(/\s+/g, "") === compact);
})();
// 当 SKU 无真实色时,展示输入框让用户填写本次生图颜色
const showInlineColorInput = !skuHasRealColor;
const isBorrowWithoutColor =
sourceMode === "borrow" && !targetColor.trim();
const generatedViewOptions = useMemo(
() =>
AI_WHITE_BACKGROUND_VIEW_KEYS.filter((viewKey) =>
@ -868,11 +887,11 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
data-testid="ai-white-background-panel"
className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#f1f4f5] text-[#2d3335]"
>
<div className="grid min-h-0 flex-1 gap-2 overflow-hidden p-2 2xl:grid-cols-[minmax(0,1fr)_310px]">
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden p-3 2xl:grid-cols-[minmax(0,1fr)_380px]">
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[8px] bg-[#ebeef0] p-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.68)]">
<div
data-testid="ai-white-background-grid"
className="grid min-h-0 flex-1 auto-rows-[minmax(190px,1fr)] grid-cols-[repeat(auto-fit,minmax(164px,1fr))] gap-2 overflow-y-auto pr-1"
className="grid min-h-0 flex-1 auto-rows-[minmax(210px,1fr)] grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3 overflow-y-auto pr-1"
>
{AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => {
const generatedFile = generatedFiles[viewKey];
@ -1046,20 +1065,20 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
data-testid="ai-white-background-control-card"
className="flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-white shadow-[0_4px_16px_rgba(45,51,53,0.055)] ring-1 ring-[#adb3b5]/12"
>
<div className="border-b border-[#adb3b5]/12 bg-white px-3 py-2">
<div className="border-b border-[#adb3b5]/12 bg-white px-3.5 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[14px] font-black text-[#2d3335]">
<p className="truncate text-[15px] font-black text-[#2d3335]">
生成设置
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="rounded-[999px] bg-[#f1f4f5] px-2 py-0.5 text-[10px] font-black text-[#5a6062]">
<div className="flex shrink-0 items-center gap-1.5">
<span className="rounded-[999px] bg-[#f1f4f5] px-2.5 py-0.5 text-[11px] font-black text-[#5a6062]">
{getPartGroupingSourceLabel(partGroupingSource)}
</span>
<span
className={cn(
"rounded-[999px] px-2 py-0.5 text-[10px] font-black",
"rounded-[999px] px-2.5 py-0.5 text-[11px] font-black",
manifestMeta.className,
)}
>
@ -1069,28 +1088,62 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
</div>
</div>
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-[#f8f9fa] px-3 py-3">
<div className="grid min-w-0 gap-2">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-[#f8f9fa] px-3.5 py-3.5">
<div className="grid min-w-0 gap-3">
<AiWhiteBackgroundSettingsSection
title="目标与源图"
meta={sourceSettingsMeta}
dataTestId="ai-white-background-settings-source"
defaultExpanded={false}
defaultExpanded={true}
expanded={expandedSettingsSections.has("source")}
onExpandedChange={(isExpanded) =>
setSettingsSectionExpanded("source", isExpanded)
}
>
<div className="grid gap-2.5">
<AiWhiteBackgroundReadonlyField
label="目标花色"
value={targetColor.trim() || "缺少花色/颜色信息"}
tone={targetColor.trim() ? "default" : "muted"}
/>
<div className="grid gap-3">
{showInlineColorInput ? (
<div className="space-y-1.5">
<label className="block text-[11px] font-black uppercase tracking-[0.16em] text-[#5a6062]">
目标花色(本次生图颜色)
</label>
<input
type="text"
aria-label="AI 白底图目标花色"
data-testid="ai-white-background-inline-target-color"
value={inlineTargetColor}
onChange={(event) =>
onInlineTargetColorChange(event.currentTarget.value)
}
placeholder="输入本次生图颜色,如:白色 / 原木色 / 胡桃色"
maxLength={60}
className={cn(
"h-10 w-full rounded-[6px] bg-white px-3 text-[13px] font-bold text-[#2d3335] outline-none ring-1 transition focus:ring-2",
isBorrowWithoutColor
? "ring-[#c47a3a]/55 focus:ring-[#c47a3a]"
: "ring-[#adb3b5]/22 focus:ring-[#47646b]/45",
)}
/>
{isBorrowWithoutColor ? (
<p className="text-[11px] font-semibold leading-4 text-[#c47a3a]">
借源生图必须指定目标颜色,否则会跟随源条码原图色调。
</p>
) : (
<p className="text-[11px] font-medium leading-4 text-[#5a6062]/72">
该条码未配置花色,请填写本次想生成的颜色。
</p>
)}
</div>
) : (
<AiWhiteBackgroundReadonlyField
label="目标花色"
value={trimmedSkuTargetColor}
tone="default"
/>
)}
{sourceMode === "borrow" ? (
<div className="space-y-1.5">
<label className="block text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/62">
<label className="block text-[11px] font-black uppercase tracking-[0.16em] text-[#5a6062]">
源条码
</label>
<SearchableInlineSelect
@ -1114,9 +1167,9 @@ export default function NonIntegratedAiWhiteBackgroundPanel({
isBulkGenerating ||
generatingViewKeys.length > 0
}
inputClassName="h-9 w-full rounded-[4px] bg-[#f1f4f5] px-3 text-[12px] font-black text-[#2d3335] outline-none ring-1 ring-[#adb3b5]/18 transition focus:bg-white focus:ring-[#47646b]/40"
inputClassName="h-10 w-full rounded-[6px] bg-[#f1f4f5] px-3 text-[13px] font-black text-[#2d3335] outline-none ring-1 ring-[#adb3b5]/22 transition focus:bg-white focus:ring-[#47646b]/45"
panelClassName="z-50 max-h-56 overflow-auto rounded-[8px] border border-[#adb3b5]/16 bg-white p-1 shadow-[0_14px_40px_rgba(45,51,53,0.12)]"
optionClassName="flex w-full items-center rounded-[6px] px-2.5 py-2 text-left text-[12px] font-semibold text-[#2d3335] transition hover:bg-[#f1f4f5]"
optionClassName="flex w-full items-center rounded-[6px] px-2.5 py-2 text-left text-[13px] font-semibold text-[#2d3335] transition hover:bg-[#f1f4f5]"
onChange={(value) =>
onSelectedSourceBarcodeChange(
value.trim().toUpperCase(),

View File

@ -104,6 +104,26 @@ function createEmptyGeneratedFileMap() {
) as Record<AiWhiteBackgroundViewKey, NonIntegratedUploadFile | null>;
}
// 花色占位符:当 SKU 既没有 color 也没有 material 时,条码条目的 secondaryLabel 会兜底成这些文案。
// 这些不是真实花色,不能当作 targetColor 传给 AI,否则 prompt 会退化为"保持源图原色"导致颜色污染。
const AI_WHITE_BACKGROUND_PLACEHOLDER_COLOR_TEXTS = [
"未配置花色",
"未配置颜色",
"缺少花色/颜色信息",
"不改色/选择目标花色",
];
function isEffectiveAiWhiteBackgroundTargetColor(value: string | null | undefined) {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return false;
}
const compact = trimmed.replace(/\s+/g, "");
return !AI_WHITE_BACKGROUND_PLACEHOLDER_COLOR_TEXTS.some(
(placeholder) => placeholder.replace(/\s+/g, "") === compact,
);
}
function createSourceFilesByView(sourceEntry: ProductPhotoBarcodeEntry | null) {
return Object.fromEntries(
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [
@ -454,6 +474,8 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
const [partGroupingSource, setPartGroupingSource] =
useState<AiWhiteBackgroundPartGroupingSource>("heuristic");
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
// 用户在花色缺失时手动输入的本次生图颜色。优先级高于 SKU 兜底占位符,避免"未配置花色"被当真实色传给 AI。
const [inlineTargetColor, setInlineTargetColor] = useState("");
const [generatingViewKeys, setGeneratingViewKeys] = useState<
AiWhiteBackgroundViewKey[]
>([]);
@ -478,7 +500,11 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
const styleKey = currentProductPhotoStyleKey.trim().toUpperCase();
const styleDetail = styleDetails[styleKey] ?? null;
const targetColor = currentTargetBarcodeEntry?.secondaryLabel.trim() ?? "";
const skuTargetColor = currentTargetBarcodeEntry?.secondaryLabel.trim() ?? "";
// 生效颜色:用户手动输入优先;SKU 真实色次之;占位符("未配置花色"等)视为无效。
const skuHasEffectiveColor =
isEffectiveAiWhiteBackgroundTargetColor(skuTargetColor);
const targetColor = inlineTargetColor.trim() || (skuHasEffectiveColor ? skuTargetColor : "");
const currentSourceScopeKey = `${currentProductPhotoStyleKey}:${selectedTargetBarcode}`;
const isBorrowSourceMode = Boolean(
currentTargetBarcodeEntry && !currentTargetBarcodeEntry.hasOriginalImages,
@ -849,7 +875,10 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
return "请先在左侧选择目标条码。";
}
if (!targetColor.trim()) {
return "目标条码缺少花色或颜色文案。";
// 借源模式(无原图)颜色污染风险最高:源图是别人的颜色,不指定目标色会跟着源图走。
return isBorrowSourceMode
? "借源生图必须先指定目标颜色,否则会跟随源条码原图色调。请在右侧「目标花色」输入框填写本次生图颜色。"
: "目标条码缺少花色或颜色文案,请在右侧「目标花色」输入框填写本次生图颜色。";
}
if (isBorrowSourceMode && !effectiveSourceBarcode) {
return sourceBarcodeOptions.length > 0
@ -1375,6 +1404,9 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
targetBarcode={selectedTargetBarcode}
targetLabel={currentTargetBarcodeEntry?.label ?? selectedTargetBarcode}
targetColor={targetColor}
skuTargetColor={skuTargetColor}
inlineTargetColor={inlineTargetColor}
onInlineTargetColorChange={setInlineTargetColor}
targetHasOriginalImages={Boolean(
currentTargetBarcodeEntry?.hasOriginalImages,
)}