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:
parent
c172ce87bc
commit
8cc0dc5218
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user