diff --git a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx index 5e75ade4..1c4a0178 100644 --- a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { ConfigForm } from './AiRenderConfigClient' +import { ConfigForm, TAB_META } from './AiRenderConfigClient' describe('AI 服务配置模型选择', () => { const originalFetch = global.fetch @@ -9,6 +9,13 @@ describe('AI 服务配置模型选择', () => { jest.clearAllMocks() }) + it('提供独立的商品抠图服务配置入口', () => { + expect(TAB_META['image-background-removal']).toMatchObject({ + label: '商品抠图', + serviceType: 'image-background-removal', + }) + }) + it('连接成功后只能从 API Key 可用模型列表中选择', async () => { const fetchMock = jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({ diff --git a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx index e752f06b..6f7bcc50 100644 --- a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx @@ -22,6 +22,7 @@ import { Loader2, Eye, EyeOff, + ScanLine, Plug, Save, X, @@ -39,14 +40,20 @@ interface ProviderConfig { createdAt: string } -type ActiveTab = 'ai-render' | 'prompt-optimizer' +type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal' interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string } interface ModelOption { value: string; label: string } -const TAB_META: Record = { +export const TAB_META: Record = { 'ai-render': { label: '生图服务', icon: Sparkles, desc: '辅助装配 AI 效果图的外部生图接口', serviceType: 'ai-render' }, 'prompt-optimizer': { label: '提示词优化', icon: Wand2, desc: '用 AI 优化用户的粗糙描述为高质量生图 prompt', serviceType: 'prompt-optimizer' }, + 'image-background-removal': { + label: '商品抠图', + icon: ScanLine, + desc: '为商品以图搜图生成去除促销文字和场景背景的纯白底主体图', + serviceType: 'image-background-removal', + }, } function ConfigCard({ config, onActivate, onEdit, onDelete }: { @@ -322,7 +329,7 @@ export default function AiRenderConfigClient() { } rail={ - {(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META['ai-render']]>).map(([key, meta]) => ( + {(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META[ActiveTab]]>).map(([key, meta]) => ( { setActiveTab(key); setShowForm(false); setEditing(null) }} icon={meta.icon} label={meta.label} /> ))} diff --git a/furniture-3d-system/src/app/admin/ai-render/page.tsx b/furniture-3d-system/src/app/admin/ai-render/page.tsx index 45de51be..61c3420b 100644 --- a/furniture-3d-system/src/app/admin/ai-render/page.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/page.tsx @@ -4,8 +4,8 @@ import { RequireAuth } from '@/components/auth/RequireAuth' import AiRenderConfigClient from './AiRenderConfigClient' export const metadata: Metadata = { - title: 'AI 生图配置 | 后台设置', - description: '配置 AI 生图和提示词优化服务', + title: 'AI 服务配置 | 后台设置', + description: '配置 AI 生图、提示词优化和商品抠图服务', } export default function AiRenderConfigPage() { diff --git a/furniture-3d-system/src/app/api/admin/test-connection/route.ts b/furniture-3d-system/src/app/api/admin/test-connection/route.ts index a45745a5..84718d3e 100644 --- a/furniture-3d-system/src/app/api/admin/test-connection/route.ts +++ b/furniture-3d-system/src/app/api/admin/test-connection/route.ts @@ -6,7 +6,7 @@ import { providerModelCatalogService } from '@/server/services' const schema = z.object({ baseUrl: z.string().url('地址格式不正确'), - serviceType: z.enum(['ai-render', 'prompt-optimizer']), + serviceType: z.enum(['ai-render', 'prompt-optimizer', 'image-background-removal']), }) export async function POST(request: NextRequest) { diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index 8dbe65b5..c693ed66 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -34,10 +34,11 @@ function detail({ costBreakdownAvailable = false, status = 'READY' }: { statusMessage: status === 'READY' ? '预估完成' : '正在生成预估', sourceCacheHit: false, sourceSnapshot: { - status: 'READY', skuCount: 1, storedImageCount: 2, failedImageCount: 0, + status: 'READY', skuCount: 1, storedImageCount: 3, failedImageCount: 0, assets: [ { id: 'asset-main', kind: 'MAIN', status: 'STORED', storedUrl: 'https://images.example.com/main.jpg', sortOrder: 0, externalSkuIds: [] }, - { id: 'asset-detail', kind: 'DETAIL', status: 'STORED', storedUrl: 'https://images.example.com/detail.jpg', sortOrder: 1, externalSkuIds: [] }, + { id: 'asset-sku', kind: 'SKU', status: 'STORED', storedUrl: 'https://images.example.com/sku.jpg', sortOrder: 1, externalSkuIds: ['EXT-1'] }, + { id: 'asset-detail', kind: 'DETAIL', status: 'STORED', storedUrl: 'https://images.example.com/detail.jpg', sortOrder: 2, externalSkuIds: [] }, ], }, requiresRecalculation: false, hasFailedRecalculation: false, needsPriceReview: false, @@ -102,6 +103,23 @@ describe('BomCostingWorkbenchClient', () => { expect(screen.queryByText('成本构成')).not.toBeInTheDocument() }) + it('左侧图片按主图、当前 SKU 图和详情图分组切换', async () => { + mockCaseResponse(detail()) + render() + await screen.findByText('约 ¥1,350.00') + + expect(screen.getByRole('tab', { name: '主图 1' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '当前 SKU 图 1' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: '详情图 1' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: '黑胡桃色 120cm SKU 图' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('tab', { name: '主图 1' })) + expect(screen.getByRole('img', { name: '商品主图' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('tab', { name: '详情图 1' })) + expect(screen.getByRole('img', { name: '商品详情图 1' })).toBeInTheDocument() + }) + it('整件临时价的详情只展示结构事实,不展示另一套结构金额', async () => { mockCaseResponse(detail()) render() diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 2631ff95..d3675fe4 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -28,6 +28,7 @@ import type { BomCostingCaseDetail, BomCostingCaseSummary, BomCostingSkuDetail, + BomCostingSourceAssetDetail, } from '@/lib/bom-costing/client-types' import { getBomCostingCurrentTaskEvents } from './BomCostingAssetWorkspace' import { @@ -51,6 +52,15 @@ type RecalculationMode = | 'REANALYZE_SELECTED' | 'REANALYZE_ALL' +type ProductImageKind = BomCostingSourceAssetDetail['kind'] + +const PRODUCT_IMAGE_KINDS: ProductImageKind[] = ['MAIN', 'SKU', 'DETAIL'] +const PRODUCT_IMAGE_KIND_LABELS: Record = { + MAIN: '主图', + SKU: '当前 SKU 图', + DETAIL: '详情图', +} + interface RecalculationRequest { mode: RecalculationMode selectedAssetIds: string[] @@ -460,14 +470,24 @@ function ResultCanvas({ const skuAssets = sourceAssets.filter((asset) => ( asset.kind === 'SKU' && asset.externalSkuIds.includes(selectedSku.externalSkuId) )) - const galleryAssets = [ - ...skuAssets, - ...sourceAssets.filter((asset) => asset.kind === 'MAIN'), - ] + const mainAssets = sourceAssets.filter((asset) => asset.kind === 'MAIN') const detailAssets = sourceAssets.filter((asset) => asset.kind === 'DETAIL') + const imageGroups: Record = { + MAIN: mainAssets, + SKU: skuAssets, + DETAIL: detailAssets, + } + const [preferredImageKind, setPreferredImageKind] = useState('SKU') + const activeImageKind = imageGroups[preferredImageKind].length > 0 + ? preferredImageKind + : PRODUCT_IMAGE_KINDS.find((kind) => imageGroups[kind].length > 0) ?? preferredImageKind + const galleryAssets = imageGroups[activeImageKind] const [selectedAssetId, setSelectedAssetId] = useState('') const selectedAsset = galleryAssets.find((asset) => asset.id === selectedAssetId) ?? galleryAssets[0] ?? null + const selectedAssetIndex = selectedAsset + ? galleryAssets.findIndex((asset) => asset.id === selectedAsset.id) + : -1 const [detailsOpen, setDetailsOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const [recalculationMode, setRecalculationMode] = useState('REUSE_FACTS') @@ -554,11 +574,58 @@ function ResultCanvas({