feat: 增加了换背景和换装的垫图
This commit is contained in:
parent
c6dccf1c04
commit
55dcc5e6da
6
bun.lock
6
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in New Issue