瀏覽代碼

fix(inventory): apply catalog color's gradient + effect, not just hex (#1340)

  Picking a color preset from the catalog only copied color_name and
  rgba onto the spool — extra_colors (gradient stops) and effect_type
  (sparkle / wood / etc.) were silently dropped at three layers above
  the API: the SpoolFormModal state shape, the CatalogDisplayColor
  mapping in ColorSection, and the selectColor handler itself. All
  three widened to carry both fields through.

  Picking a catalog swatch now writes both fields from the entry, so
  solid presets cleanly replace previous gradients. Recent-colors and
  the hardcoded-fallback palette stay as plain hex pickers — they
  don't touch extras/effect since they aren't full presets.

  Also fixed the en-US `colour` → `color` drift in 8 locale files
  that the reporter flagged.
maziggy 1 周之前
父節點
當前提交
b51ef33472

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 116 - 0
frontend/src/__tests__/components/spool-form/ColorSectionCatalogExtras.test.tsx

@@ -0,0 +1,116 @@
+/**
+ * Regression test for #1340: clicking a catalog color must apply its
+ * extra_colors (gradient stops) and effect_type alongside hex + name.
+ *
+ * Previously only hex + name were copied onto the spool, so a catalog entry
+ * configured as a multi-color gradient with a visual effect would degrade to
+ * a flat solid swatch the moment the user picked it.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../../i18n';
+import { ColorSection } from '../../../components/spool-form/ColorSection';
+import { defaultFormData } from '../../../components/spool-form/types';
+
+function renderColorSection(opts: {
+  catalogColors: Parameters<typeof ColorSection>[0]['catalogColors'];
+  formData?: Partial<typeof defaultFormData>;
+}) {
+  const formData = {
+    ...defaultFormData,
+    brand: 'Bambu Lab',
+    material: 'PLA',
+    ...opts.formData,
+  };
+  const updateField = vi.fn();
+  render(
+    <I18nextProvider i18n={i18n}>
+      <ColorSection
+        formData={formData}
+        updateField={updateField}
+        recentColors={[]}
+        onColorUsed={vi.fn()}
+        catalogColors={opts.catalogColors}
+      />
+    </I18nextProvider>,
+  );
+  return { updateField };
+}
+
+describe('ColorSection — catalog color picker (#1340)', () => {
+  it('applies extra_colors and effect_type from the catalog entry', () => {
+    const { updateField } = renderColorSection({
+      catalogColors: [
+        {
+          manufacturer: 'Bambu Lab',
+          color_name: 'Galaxy PLA',
+          hex_color: '#1a1a2e',
+          material: 'PLA',
+          extra_colors: 'ec984c,6cd4bc,a66eb9,d87694',
+          effect_type: 'sparkle',
+        },
+      ],
+    });
+
+    // Catalog swatches are rendered as buttons with the hex as their background;
+    // the most reliable handle is the title text built from name + manufacturer.
+    const swatch = screen.getByTitle(/Galaxy PLA \(Bambu Lab/);
+    fireEvent.click(swatch);
+
+    expect(updateField).toHaveBeenCalledWith('rgba', '1A1A2EFF');
+    expect(updateField).toHaveBeenCalledWith('color_name', 'Galaxy PLA');
+    expect(updateField).toHaveBeenCalledWith('extra_colors', 'ec984c,6cd4bc,a66eb9,d87694');
+    expect(updateField).toHaveBeenCalledWith('effect_type', 'sparkle');
+  });
+
+  it('clears existing extras when a catalog entry has none (preset replaces look)', () => {
+    const { updateField } = renderColorSection({
+      catalogColors: [
+        {
+          manufacturer: 'Bambu Lab',
+          color_name: 'Plain Red',
+          hex_color: '#ff0000',
+          material: 'PLA',
+          extra_colors: null,
+          effect_type: null,
+        },
+      ],
+      formData: { extra_colors: 'aabbcc,ddeeff', effect_type: 'sparkle' },
+    });
+
+    const swatch = screen.getByTitle(/Plain Red \(Bambu Lab/);
+    fireEvent.click(swatch);
+
+    // The catalog entry is a complete preset — picking a solid preset must
+    // wipe the previously-set gradient and effect, not leave them clinging.
+    expect(updateField).toHaveBeenCalledWith('extra_colors', '');
+    expect(updateField).toHaveBeenCalledWith('effect_type', '');
+  });
+
+  it('leaves extras and effect untouched when a plain swatch is clicked', () => {
+    // The fallback hardcoded palette renders when no catalog colors match the
+    // brand/material. Those are plain hex pickers — they must NOT clobber an
+    // existing gradient on the spool.
+    const { updateField } = renderColorSection({
+      catalogColors: [],
+      formData: {
+        brand: '',
+        material: '',
+        extra_colors: 'aabbcc,ddeeff',
+        effect_type: 'sparkle',
+      },
+    });
+
+    // The QUICK_COLORS palette includes Black/White/etc. Pick any one.
+    const whiteSwatch = screen.getByTitle('White');
+    fireEvent.click(whiteSwatch);
+
+    expect(updateField).toHaveBeenCalledWith('rgba', 'FFFFFFFF');
+    // No extra_colors / effect_type updates — those buttons aren't presets.
+    const calledKeys = updateField.mock.calls.map(c => c[0]);
+    expect(calledKeys).not.toContain('extra_colors');
+    expect(calledKeys).not.toContain('effect_type');
+  });
+});

+ 10 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -80,7 +80,16 @@ export function SpoolFormModal({
   const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
 
   // Color catalog
-  const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
+  const [colorCatalog, setColorCatalog] = useState<{
+    manufacturer: string;
+    color_name: string;
+    hex_color: string;
+    material: string | null;
+    // #1340: gradient + effect carried from the catalog entry through to the
+    // color picker so they're applied alongside hex + name on selection.
+    extra_colors?: string | null;
+    effect_type?: string | null;
+  }[]>([]);
 
   // Color state
   const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);

+ 29 - 2
frontend/src/components/spool-form/ColorSection.tsx

@@ -44,10 +44,31 @@ export function ColorSection({
     return currentHex.toUpperCase() === hex.toUpperCase();
   };
 
-  const selectColor = (hex: string, name: string) => {
+  const selectColor = (
+    hex: string,
+    name: string,
+    // #1340: catalog entries carry an optional gradient + effect. Pass them in
+    // (even as empty strings) to overwrite the spool's existing values — the
+    // catalog entry is a complete preset, the user explicitly chose its look.
+    // Pass `undefined` (the default, used by recent/fallback swatches) to
+    // leave any existing gradient/effect untouched — those buttons are plain
+    // hex pickers, not full presets.
+    extraColors?: string | null,
+    effectType?: string | null,
+  ) => {
     // Store as RRGGBBAA (with FF alpha)
     updateField('rgba', hex.toUpperCase() + 'FF');
     updateField('color_name', name);
+    if (extraColors !== undefined) {
+      const next = extraColors ?? '';
+      setExtraColorsDraft(next);
+      setExtraColorsErrors([]);
+      lastCommittedExtraColorsRef.current = next;
+      updateField('extra_colors', next);
+    }
+    if (effectType !== undefined) {
+      updateField('effect_type', effectType ?? '');
+    }
     onColorUsed({ name, hex });
   };
 
@@ -82,6 +103,8 @@ export function ColorSection({
           hex: c.hex_color.replace('#', '').substring(0, 6),
           manufacturer: c.manufacturer,
           material: typeof c.material === 'string' ? c.material : undefined,
+          extra_colors: c.extra_colors ?? null,
+          effect_type: c.effect_type ?? null,
         }));
       }
     }
@@ -101,6 +124,8 @@ export function ColorSection({
           hex: c.hex_color.replace('#', '').substring(0, 6),
           manufacturer: c.manufacturer,
           material: typeof c.material === 'string' ? c.material : undefined,
+          extra_colors: c.extra_colors ?? null,
+          effect_type: c.effect_type ?? null,
         }));
       }
       // Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
@@ -133,6 +158,8 @@ export function ColorSection({
           hex: c.hex_color.replace('#', '').substring(0, 6),
           manufacturer: c.manufacturer,
           material: typeof c.material === 'string' ? c.material : undefined,
+          extra_colors: c.extra_colors ?? null,
+          effect_type: c.effect_type ?? null,
         }));
       }
     }
@@ -271,7 +298,7 @@ export function ColorSection({
               <button
                 key={`${color.hex}-${color.name}-${color.manufacturer ?? ''}`}
                 type="button"
-                onClick={() => selectColor(color.hex, color.name)}
+                onClick={() => selectColor(color.hex, color.name, color.extra_colors, color.effect_type)}
                 className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'

+ 13 - 1
frontend/src/components/spool-form/types.ts

@@ -7,6 +7,11 @@ export interface CatalogDisplayColor {
   hex: string;
   manufacturer?: string;
   material?: string;
+  // #1340: a catalog entry can carry a gradient + visual effect. When the user
+  // picks the entry, we copy these onto the spool's color metadata — the bug
+  // was that they were never propagated past the API layer.
+  extra_colors?: string | null;
+  effect_type?: string | null;
 }
 
 // Form data structure
@@ -117,7 +122,14 @@ export interface FilamentSectionProps extends SectionProps {
 export interface ColorSectionProps extends SectionProps {
   recentColors: ColorPreset[];
   onColorUsed: (color: ColorPreset) => void;
-  catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];
+  catalogColors: {
+    manufacturer: string;
+    color_name: string;
+    hex_color: string;
+    material: string | null;
+    extra_colors?: string | null;
+    effect_type?: string | null;
+  }[];
 }
 
 // Additional section props

+ 4 - 4
frontend/src/i18n/locales/en.ts

@@ -3588,8 +3588,8 @@ export default {
     showAll: 'Show all',
     noColorsFound: 'No colors match your search',
     noResults: 'No matches found',
-    // Multi-colour gradient + visual effect (#1154)
-    extraColorsLabel: 'Extra colours',
+    // Multi-color gradient + visual effect (#1154)
+    extraColorsLabel: 'Extra colors',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
     extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
     extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
@@ -3608,7 +3608,7 @@ export default {
       rainbow: 'Rainbow',
       metal: 'Metal',
       translucent: 'Translucent',
-      // Multi-colour structural variants
+      // Multi-color structural variants
       gradient: 'Gradient',
       dualColor: 'Dual Color',
       triColor: 'Tri Color',
@@ -4238,7 +4238,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Tailscale integration',

+ 3 - 3
frontend/src/i18n/locales/fr.ts

@@ -3572,8 +3572,8 @@ export default {
     showAll: 'Toutes',
     noColorsFound: 'Aucune couleur correspondante',
     noResults: 'Aucun résultat',
-    // Multi-colour gradient + visual effect (#1154) — English fallback.
-    extraColorsLabel: 'Extra colours',
+    // Multi-color gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colors',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
     extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
     extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
@@ -4219,7 +4219,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Intégration Tailscale',

+ 3 - 3
frontend/src/i18n/locales/it.ts

@@ -3571,8 +3571,8 @@ export default {
     showAll: 'Mostra tutto',
     noColorsFound: 'Nessun colore corrisponde alla ricerca',
     noResults: 'Nessun risultato trovato',
-    // Multi-colour gradient + visual effect (#1154) — English fallback.
-    extraColorsLabel: 'Extra colours',
+    // Multi-color gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colors',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
     extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
     extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
@@ -4218,7 +4218,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Integrazione Tailscale',

+ 3 - 3
frontend/src/i18n/locales/ja.ts

@@ -3583,8 +3583,8 @@ export default {
     showAll: 'すべて表示',
     noColorsFound: '一致する色がありません',
     noResults: '結果なし',
-    // Multi-colour gradient + visual effect (#1154) — English fallback.
-    extraColorsLabel: 'Extra colours',
+    // Multi-color gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colors',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
     extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
     extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
@@ -4230,7 +4230,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Tailscale統合',

+ 3 - 3
frontend/src/i18n/locales/pt-BR.ts

@@ -3571,8 +3571,8 @@ export default {
     showAll: 'Mostrar tudo',
     noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
     noResults: 'Nenhum resultado encontrado',
-    // Multi-colour gradient + visual effect (#1154) — English fallback.
-    extraColorsLabel: 'Extra colours',
+    // Multi-color gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colors',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
     extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
     extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
@@ -4218,7 +4218,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Integração Tailscale',

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4218,7 +4218,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Tailscale 集成',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4218,7 +4218,7 @@ export default {
     },
     queueForceColorMatch: {
       title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     tailscaleDisabled: {
       title: 'Tailscale 整合',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-oQDjU99K.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D3mBzx5f.js"></script>
+    <script type="module" crossorigin src="/assets/index-oQDjU99K.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BkYu3kLs.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff