feat: 增加了换背景和换装的垫图

This commit is contained in:
张德辉 2025-07-02 14:41:13 +08:00
parent c6dccf1c04
commit 55dcc5e6da
10 changed files with 411 additions and 70 deletions

View File

@ -8,6 +8,7 @@
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@ -19,6 +20,7 @@
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lucide-react": "^0.523.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
@ -196,6 +198,8 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@ -444,6 +448,8 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],

View File

@ -17,6 +17,7 @@
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",

View File

@ -0,0 +1,92 @@
import React, { useRef } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface BgPaddingMultiValueItem {
file: File;
text: string;
}
export type BgPaddingMultiValue = BgPaddingMultiValueItem[];
export interface BgPaddingMultiSelectProps {
value: BgPaddingMultiValue;
onChange: (value: BgPaddingMultiValue) => void;
className?: string;
}
export const BgPaddingMultiSelect: React.FC<BgPaddingMultiSelectProps> = ({ value, onChange, className }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
// 触发文件选择
const handleAddClick = () => {
fileInputRef.current?.click();
};
// 处理多文件选择
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
const newItems = files.map(file => ({
file,
text: file.name.replace(/\.[^.]+$/, ''),
}));
onChange([...value, ...newItems]);
// 清空 input 以便连续选择同一文件
e.target.value = '';
}
};
// 单项描述变更
const handleTextChange = (idx: number, text: string) => {
const newArr = value.map((item, i) => (i === idx ? { ...item, text } : item));
onChange(newArr);
};
// 移除某项
const handleRemove = (idx: number) => {
const newArr = value.filter((_, i) => i !== idx);
onChange(newArr);
};
return (
<div className={cn('space-y-4 flex flex-col gap-2', className)}>
<Button type='button' onClick={handleAddClick} variant='outline'>
</Button>
<Input type='file' accept='image/*' multiple ref={fileInputRef} style={{ display: 'none' }} onChange={handleFilesChange} />
<div className='space-y-6'>
{value.map((item, idx) => (
<div key={idx} className='border rounded-md p-3 flex flex-col gap-2 relative bg-muted/30'>
<div className='flex items-center gap-2'>
<img
src={URL.createObjectURL(item.file)}
alt={item.text || item.file.name}
className='h-12 w-auto rounded-md object-cover border'
style={{ maxWidth: 96 }}
/>
<span className='text-sm text-muted-foreground wrap-anywhere'>{item.file.name}</span>
<Button type='button' size='sm' variant='ghost' onClick={() => handleRemove(idx)}>
</Button>
</div>
<Label htmlFor={`bg-multi-text-${idx}`}>/</Label>
<textarea
id={`bg-multi-text-${idx}`}
className={cn(
'w-full min-h-[60px] rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'resize-vertical'
)}
value={item.text}
onChange={e => handleTextChange(idx, e.target.value)}
placeholder='请输入图片描述或备注...'
/>
</div>
))}
</div>
</div>
);
};
export default BgPaddingMultiSelect;

View File

@ -0,0 +1,59 @@
import React, { useRef } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
export interface BgPaddingValue {
file?: File;
text: string;
}
export interface BgPaddingSelectProps {
value: BgPaddingValue;
onChange: (value: BgPaddingValue) => void;
className?: string;
}
export const BgPaddingSelect: React.FC<BgPaddingSelectProps> = ({ value, onChange, className }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
let newText = value.text;
if (!value.text) {
// 自动填充文件名(去除后缀)
const name = file.name.replace(/\.[^.]+$/, '');
newText = name;
}
onChange({ file, text: newText });
}
};
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange({ ...value, text: e.target.value });
};
return (
<div className={cn('space-y-4', className)}>
<div>
<Label htmlFor='bg-file'></Label>
<Input id='bg-file' type='file' accept='image/*' ref={fileInputRef} onChange={handleFileChange} />
{value.file && <div className='mt-2 text-sm text-muted-foreground'>{value.file.name}</div>}
</div>
<div>
<Label htmlFor='bg-text'>/</Label>
<textarea
id='bg-text'
className={cn(
'w-full min-h-[80px] rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'resize-vertical'
)}
value={value.text}
onChange={handleTextChange}
placeholder='请输入图片描述或备注...'
/>
</div>
</div>
);
};

View File

@ -1,4 +1,9 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { X } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
interface CascaderNode {
title: string;
@ -26,8 +31,6 @@ const getLeafPaths = (tree: CascaderNode[], prefix: string[] = []): string[][] =
};
const CascaderMultiSelect: React.FC<CascaderMultiSelectProps> = ({ options, value, onChange }) => {
const [open, setOpen] = useState(false);
// 判断某路径是否已选
const isSelected = (path: string[]) => value.some(v => v.join('/') === path.join('/'));
@ -47,12 +50,17 @@ const CascaderMultiSelect: React.FC<CascaderMultiSelectProps> = ({ options, valu
// 渲染 tag
const renderTags = () => (
<div className='flex flex-wrap gap-1'>
<div className={cn(value.length > 0 ? 'mb-2' : '', 'flex flex-wrap gap-2')}>
{value.map(v => (
<span key={v.join('/')} className='bg-primary/10 text-primary px-2 py-0.5 rounded text-xs flex items-center gap-1'>
<span key={v.join('/')} className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary'>
{v.join(' / ')}
<button type='button' className='ml-1 text-xs hover:text-red-500' onClick={() => handleRemove(v)} style={{ lineHeight: 1 }}>
×
<button
type='button'
className='ml-1 rounded-full p-0.5 hover:bg-red-100 hover:text-red-600 transition-colors'
onClick={() => handleRemove(v)}
aria-label='移除'
>
<X className='w-3 h-3' />
</button>
</span>
))}
@ -77,8 +85,8 @@ const CascaderMultiSelect: React.FC<CascaderMultiSelectProps> = ({ options, valu
} else {
return (
<li key={path.join('/')} className='flex items-center gap-1 py-1 pl-2 hover:bg-muted/50 rounded'>
<label className='flex items-center gap-1 cursor-pointer w-full'>
<input type='checkbox' checked={isSelected(path)} onChange={() => handleSelect(path)} className='accent-primary' />
<label className='flex items-center gap-2 cursor-pointer w-full text-sm'>
<Checkbox checked={isSelected(path)} onCheckedChange={() => handleSelect(path)} className='border-muted-foreground/20' />
<span>{node.title}</span>
</label>
</li>
@ -88,30 +96,19 @@ const CascaderMultiSelect: React.FC<CascaderMultiSelectProps> = ({ options, valu
</ul>
);
// 渲染全部展开的级联下拉
const renderCascader = () => (
<div
className='absolute z-50 bg-white border rounded shadow-lg p-2 mt-1 min-w-[220px] max-h-96 overflow-auto'
style={{ boxShadow: '0 4px 24px 0 rgba(0,0,0,0.10)' }}
>
<style>{`
.custom-scrollbar::-webkit-scrollbar { width: 6px; background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 3px; }
`}</style>
<div className='custom-scrollbar'>{renderTree(options)}</div>
</div>
);
return (
<div className='relative'>
<div className='flex items-center gap-2 flex-wrap'>
{renderTags()}
<button type='button' className='border px-2 py-1 rounded text-xs bg-white hover:bg-muted' onClick={() => setOpen(v => !v)}>
</button>
</div>
{open && renderCascader()}
{open && <div className='fixed inset-0 z-40' onClick={() => setOpen(false)} />}
<div className='flex flex-col '>
{renderTags()}
<Popover>
<PopoverTrigger asChild>
<Button type='button' variant='outline'>
</Button>
</PopoverTrigger>
<PopoverContent className='p-2'>
<div className='min-w-[220px] max-h-96 overflow-auto'>{renderTree(options)}</div>
</PopoverContent>
</Popover>
</div>
);
};

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -1,8 +1,11 @@
import { BgPaddingMultiSelect, type BgPaddingMultiValue } from '@/components/block/BgPaddingMultiSelect';
import CascaderMultiSelect from '@/components/block/CascaderMultiSelect';
import { Button } from '@/components/ui/button';
import { Card, CardAction, CardContent, CardHeader } from '@/components/ui/card';
import { X } from 'lucide-react';
import React from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
interface ClothingCardProps {
imageUrl: string;
@ -11,9 +14,24 @@ interface ClothingCardProps {
scenes?: string[][];
onScenesChange?: (scenes: string[][]) => void;
fileName?: string;
bgMode: 'custom' | 'scene';
onChangeBgMode: (bgMode: 'custom' | 'scene') => void;
paddingList?: BgPaddingMultiValue;
onPaddingListChange?: (paddingList: BgPaddingMultiValue) => void;
}
const ClothingCard: React.FC<ClothingCardProps> = ({ imageUrl, onRemove, scenesOptions = [], scenes = [], onScenesChange, fileName }) => {
const ClothingCard: React.FC<ClothingCardProps> = ({
imageUrl,
onRemove,
scenesOptions = [],
scenes = [],
onScenesChange,
fileName,
bgMode,
onChangeBgMode,
paddingList,
onPaddingListChange,
}) => {
return (
<Card className='relative md:flex-row gap-6 group hover:shadow-lg transition-shadow'>
<CardHeader className='p-0'>
@ -36,11 +54,27 @@ const ClothingCard: React.FC<ClothingCardProps> = ({ imageUrl, onRemove, scenesO
{fileName && <div className='mt-2 text-xs text-muted-foreground break-all max-w-[200px]'>{fileName}</div>}
</div>
<div className='flex-1 flex flex-col gap-6'>
{/* 场景级联多选 */}
<div className='w-full flex flex-row items-center gap-2'>
{/* 场景级联多选/自定义上传切换 */}
<div className='w-full flex flex-row items-start gap-4'>
<label className='w-16 text-right flex-shrink-0'></label>
<div className='flex-1'>
<CascaderMultiSelect options={scenesOptions} value={scenes || []} onChange={onScenesChange || (() => {})} />
<div className='flex flex-col gap-2 min-h-40 w-full overflow-y-auto'>
<div className='flex items-center gap-2 h-6'>
<Switch
id='custom-mode-switch'
checked={bgMode === 'custom'}
onCheckedChange={() => onChangeBgMode(bgMode === 'custom' ? 'scene' : 'custom')}
/>
<Label htmlFor='custom-mode-switch'>{bgMode === 'custom' ? '自定义垫图' : '内置场景'}</Label>
</div>
<div className='w-full flex flex-row items-center gap-2'>
<div className='flex-1'>
{bgMode === 'scene' ? (
<CascaderMultiSelect options={scenesOptions} value={scenes || []} onChange={onScenesChange || (() => {})} />
) : (
<BgPaddingMultiSelect value={paddingList || []} onChange={onPaddingListChange || (() => {})} />
)}
</div>
</div>
</div>
</div>
</div>

View File

@ -5,7 +5,13 @@ import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import ClothingCard from './components/ClothingCard';
import { api, type Body_local_cloud_async_change_bg_api_v2_cloud_batch_change_bg_post } from '@/api';
import {
api,
type Body_async_cloud_change_bg_v3_api_v3_cloud_batch_change_bg_post,
type Body_async_cloud_gen_images_v3_api_v3_cloud_batch_edit_images_post,
type Body_local_cloud_async_change_bg_api_v2_cloud_batch_change_bg_post,
} from '@/api';
import { type BgPaddingMultiValue } from '@/components/block/BgPaddingMultiSelect';
// 五级联动的 tag schema
const tagSchema = z.object({
@ -20,6 +26,8 @@ const formSchema = z.object({
interface TagForm {
scenes?: string[][];
paddingList?: BgPaddingMultiValue;
bgMode?: 'custom' | 'scene';
}
interface FormValues {
@ -93,6 +101,20 @@ const TryOnPage: React.FC = () => {
form.setValue('tags', newTags);
};
// 自定义上传
const handlePaddingListChange = (idx: number, paddingList: BgPaddingMultiValue) => {
const newTags = [...form.getValues('tags')];
newTags[idx] = { ...newTags[idx], paddingList };
form.setValue('tags', newTags);
};
// 切换背景模式
const handleBgModeChange = (idx: number, bgMode: 'custom' | 'scene') => {
const newTags = [...form.getValues('tags')];
newTags[idx] = { ...newTags[idx], bgMode };
form.setValue('tags', newTags);
};
// 递归查找 prompt
function findPromptByPath(tree: any[], path: string[]): string {
let node: any = null;
@ -114,19 +136,34 @@ const TryOnPage: React.FC = () => {
try {
for (let i = 0; i < values.clothing_images.length; i++) {
const tag = values.tags[i];
const scenesArr: string[][] = tag.scenes || [];
for (const path of scenesArr) {
const scene = {
title: path.join('/'),
prompt: findPromptByPath(scenesOptions, path),
};
const formData: Body_local_cloud_async_change_bg_api_v2_cloud_batch_change_bg_post = {
clothing_images: [values.clothing_images[i]],
scenes_list: JSON.stringify([{ title: scene.title, prompt: scene.prompt }]),
};
// eslint-disable-next-line no-await-in-loop
const res = await api.ImageGenerateService.localCloudAsyncChangeBgApiV2CloudBatchChangeBgPost({ formData });
results.push(res);
const bgMode = tag.bgMode || 'scene';
if (bgMode === 'custom') {
// 自定义模式
const paddingList = tag.paddingList || [];
for (const padding of paddingList) {
const formData: Body_async_cloud_change_bg_v3_api_v3_cloud_batch_change_bg_post = {
clothing_images: [values.clothing_images[i]],
padding_images: [padding.file],
bg_prompts: padding.text,
};
const res = await api.ImageGenerateService.asyncCloudChangeBgV3ApiV3CloudBatchChangeBgPost({ formData });
results.push(res);
}
} else {
// 场景模式
const scenesArr: string[][] = tag.scenes || [];
for (const path of scenesArr) {
const scene = {
title: path.join('/'),
prompt: findPromptByPath(scenesOptions, path),
};
const formData: Body_local_cloud_async_change_bg_api_v2_cloud_batch_change_bg_post = {
clothing_images: [values.clothing_images[i]],
scenes_list: JSON.stringify([{ title: scene.title, prompt: scene.prompt }]),
};
const res = await api.ImageGenerateService.localCloudAsyncChangeBgApiV2CloudBatchChangeBgPost({ formData });
results.push(res);
}
}
}
setResult(results);
@ -160,6 +197,10 @@ const TryOnPage: React.FC = () => {
scenes={tag.scenes || []}
onScenesChange={scenes => handleScenesChange(idx, scenes)}
fileName={fileName}
paddingList={tag.paddingList || []}
onPaddingListChange={paddingList => handlePaddingListChange(idx, paddingList)}
bgMode={tag.bgMode || 'scene'}
onChangeBgMode={bgMode => handleBgModeChange(idx, bgMode)}
/>
);
})}

View File

@ -1,7 +1,10 @@
import { BgPaddingMultiSelect, type BgPaddingMultiValue } from '@/components/block/BgPaddingMultiSelect';
import CascaderMultiSelect from '@/components/block/CascaderMultiSelect';
import { Button } from '@/components/ui/button';
import { Card, CardAction, CardContent, CardHeader } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { X } from 'lucide-react';
import React from 'react';
@ -33,6 +36,11 @@ interface ClothingCardProps {
scenes?: string[][];
onScenesChange?: (scenes: string[][]) => void;
fileName?: string;
bgMode: 'custom' | 'scene';
onChangeBgMode: (bgMode: 'custom' | 'scene') => void;
paddingList?: BgPaddingMultiValue;
onPaddingListChange?: (paddingList: BgPaddingMultiValue) => void;
}
const LABELS = ['性别', '类别', '尺寸', '材质', '颜色'];
@ -49,6 +57,10 @@ const ClothingCard: React.FC<ClothingCardProps> = ({
scenes = [],
onScenesChange,
fileName,
bgMode,
onChangeBgMode,
paddingList,
onPaddingListChange,
}) => {
return (
<Card className='relative md:flex-row gap-6 group hover:shadow-lg transition-shadow'>
@ -100,11 +112,29 @@ const ClothingCard: React.FC<ClothingCardProps> = ({
</div>
);
})}
{/* 场景级联多选 */}
<div className='w-full flex flex-row items-center gap-2'>
<label className='w-16 text-right flex-shrink-0'></label>
<div className='flex-1'>
<CascaderMultiSelect options={scenesOptions} value={scenes || []} onChange={onScenesChange || (() => {})} />
<div className='flex-1 flex flex-col gap-6'>
{/* 场景级联多选/自定义上传切换 */}
<div className='w-full flex flex-row items-start gap-4'>
<label className='w-16 text-right flex-shrink-0'></label>
<div className='flex flex-col gap-2 min-h-40 w-full overflow-y-auto'>
<div className='flex items-center gap-2 h-6'>
<Switch
id='custom-mode-switch'
checked={bgMode === 'custom'}
onCheckedChange={() => onChangeBgMode(bgMode === 'custom' ? 'scene' : 'custom')}
/>
<Label htmlFor='custom-mode-switch'>{bgMode === 'custom' ? '自定义垫图' : '内置场景'}</Label>
</div>
<div className='w-full flex flex-row items-center gap-2'>
<div className='flex-1'>
{bgMode === 'scene' ? (
<CascaderMultiSelect options={scenesOptions} value={scenes || []} onChange={onScenesChange || (() => {})} />
) : (
<BgPaddingMultiSelect value={paddingList || []} onChange={onPaddingListChange || (() => {})} />
)}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -6,7 +6,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import ClothingCard from './components/ClothingCard';
import { api } from '@/api';
import { api, type Body_async_cloud_gen_images_v3_api_v3_cloud_batch_edit_images_post } from '@/api';
import { type BgPaddingMultiValue } from '@/components/block/BgPaddingMultiSelect';
// 五级联动的 tag schema
const tagSchema = z.object({
@ -30,7 +31,9 @@ interface TagForm {
size: string;
material: string;
color: string;
bgMode?: 'custom' | 'scene';
scenes?: string[][];
paddingList?: BgPaddingMultiValue;
}
interface FormValues {
@ -123,6 +126,20 @@ const TryOnPage: React.FC = () => {
form.setValue('tags', newTags);
};
// 自定义上传
const handlePaddingListChange = (idx: number, paddingList: BgPaddingMultiValue) => {
const newTags = [...form.getValues('tags')];
newTags[idx] = { ...newTags[idx], paddingList };
form.setValue('tags', newTags);
};
// 切换背景模式
const handleBgModeChange = (idx: number, bgMode: 'custom' | 'scene') => {
const newTags = [...form.getValues('tags')];
newTags[idx] = { ...newTags[idx], bgMode };
form.setValue('tags', newTags);
};
// 计算每个卡片的options
const getTagOptions = (idx: number) => {
const tag = tags[idx] || {};
@ -163,21 +180,35 @@ const TryOnPage: React.FC = () => {
try {
for (let i = 0; i < values.clothing_images.length; i++) {
const tag = values.tags[i];
const bgMode = tag.bgMode || 'scene';
const scenesArr: string[][] = tag.scenes || [];
for (const path of scenesArr) {
const scene = {
title: path.join('/'),
prompt: findPromptByPath(scenesOptions, path),
};
const formData: Body_local_async_gen_images_api_v2_local_batch_edit_images_post = {
clothing_images: [values.clothing_images[i]],
tag_list: JSON.stringify([[tag.sex, tag.category, tag.size, tag.material, tag.color].join('_')]),
scenes_list: JSON.stringify([{ title: scene.title, prompt: scene.prompt }]),
mode: 'both',
};
// eslint-disable-next-line no-await-in-loop
const res = await api.ImageGenerateService.localAsyncGenImagesApiV2LocalBatchEditImagesPost({ formData });
results.push(res);
if (bgMode === 'custom') {
const paddingList = tag.paddingList || [];
for (const padding of paddingList) {
const formData: Body_async_cloud_gen_images_v3_api_v3_cloud_batch_edit_images_post = {
clothing_images: [values.clothing_images[i]],
tag_list: JSON.stringify([[tag.sex, tag.category, tag.size, tag.material, tag.color].join('_')]),
padding_clothes: [padding.file],
bg_prompts: padding.text,
};
const res = await api.ImageGenerateService.asyncCloudGenImagesV3ApiV3CloudBatchEditImagesPost({ formData });
results.push(res);
}
} else {
for (const path of scenesArr) {
const scene = {
title: path.join('/'),
prompt: findPromptByPath(scenesOptions, path),
};
const formData: Body_local_async_gen_images_api_v2_local_batch_edit_images_post = {
clothing_images: [values.clothing_images[i]],
tag_list: JSON.stringify([[tag.sex, tag.category, tag.size, tag.material, tag.color].join('_')]),
scenes_list: JSON.stringify([{ title: scene.title, prompt: scene.prompt }]),
mode: 'both',
};
const res = await api.ImageGenerateService.localAsyncGenImagesApiV2LocalBatchEditImagesPost({ formData });
results.push(res);
}
}
}
setResult(results);
@ -216,6 +247,10 @@ const TryOnPage: React.FC = () => {
scenes={tag.scenes || []}
onScenesChange={scenes => handleScenesChange(idx, scenes)}
fileName={fileName}
bgMode={tag.bgMode || 'scene'}
onChangeBgMode={bgMode => handleBgModeChange(idx, bgMode)}
paddingList={tag.paddingList || []}
onPaddingListChange={paddingList => handlePaddingListChange(idx, paddingList)}
/>
);
})}