feat: 增加换背景

This commit is contained in:
2025-07-01 16:02:48 +08:00
parent c335873132
commit c979c291e6
10 changed files with 4301 additions and 143 deletions

3915
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,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",
@@ -51,11 +52,11 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"openapi-typescript": "^7.0.0",
"openapi-typescript-codegen": "^0.27.0",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.0.0",
"openapi-typescript": "^7.0.0",
"openapi-typescript-codegen": "^0.27.0"
"vite": "^7.0.0"
}
}

View File

@@ -1,14 +1,16 @@
import { ConfirmDialog } from '@/components/block/confirm-dialog';
import { Sidebar, SidebarContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from '@/components/ui/sidebar';
import { Home, List, Users, Video } from 'lucide-react';
import { Home, Image, List, Users, Video } from 'lucide-react';
import { Link, Route, Routes, useLocation } from 'react-router-dom';
import ModelPage from './pages/ModelPage';
import TryOnPage from './pages/TryOnPage';
import TryOnTasksPage from './pages/TryOnTasksPage';
import TryOnVideoTaskPage from './pages/TryOnVideoTaskPage';
import ChangeBgPage from './pages/ChangeBgPage';
const menu = [
{ path: '/', label: '首页', icon: <Home className='mr-2' /> },
{ path: '/change-bg', label: '图片换背景', icon: <Image className='mr-2' /> },
{ path: '/tasks', label: '任务列表', icon: <List className='mr-2' /> },
{ path: '/video-tasks', label: '视频任务进度', icon: <Video className='mr-2' /> },
{ path: '/models', label: '模特维护', icon: <Users className='mr-2' /> },
@@ -39,6 +41,7 @@ function App() {
<main className='flex-1 p-8'>
<Routes>
<Route path='/' element={<TryOnPage />} />
<Route path='/change-bg' element={<ChangeBgPage />} />
<Route path='/models' element={<ModelPage />} />
<Route path='/tasks' element={<TryOnTasksPage />} />
<Route path='/video-tasks' element={<TryOnVideoTaskPage />} />

View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
interface CascaderNode {
title: string;
children?: CascaderNode[];
prompt?: string;
}
interface CascaderMultiSelectProps {
options: CascaderNode[];
value: string[][]; // 每个已选项为路径数组,如 ['户外', '公园', '公园1']
onChange: (value: string[][]) => void;
}
const getLeafPaths = (tree: CascaderNode[], prefix: string[] = []): string[][] => {
let result: string[][] = [];
for (const node of tree) {
const path = [...prefix, node.title];
if (node.children && node.children.length > 0) {
result = result.concat(getLeafPaths(node.children, path));
} else {
result.push(path);
}
}
return result;
};
const CascaderMultiSelect: React.FC<CascaderMultiSelectProps> = ({ options, value, onChange }) => {
const [open, setOpen] = useState(false);
// 判断某路径是否已选
const isSelected = (path: string[]) => value.some(v => v.join('/') === path.join('/'));
// 选中叶子节点
const handleSelect = (path: string[]) => {
if (!isSelected(path)) {
onChange([...value, path]);
} else {
onChange(value.filter(v => v.join('/') !== path.join('/')));
}
};
// 删除 tag
const handleRemove = (path: string[]) => {
onChange(value.filter(v => v.join('/') !== path.join('/')));
};
// 渲染 tag
const renderTags = () => (
<div className='flex flex-wrap gap-1'>
{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'>
{v.join(' / ')}
<button type='button' className='ml-1 text-xs hover:text-red-500' onClick={() => handleRemove(v)} style={{ lineHeight: 1 }}>
×
</button>
</span>
))}
</div>
);
// 递归渲染所有层级
const renderTree = (nodes: CascaderNode[], prefix: string[] = [], level = 0) => (
<ul className={level === 0 ? '' : 'border-l border-muted-foreground/10 ml-2 pl-2'}>
{nodes.map(node => {
const path = [...prefix, node.title];
if (node.children && node.children.length > 0) {
return (
<li key={path.join('/')} className='mb-1'>
<div className='font-medium text-sm flex items-center gap-1 text-muted-foreground mb-1'>
<span className='inline-block w-3 text-muted-foreground'>📂</span>
{node.title}
</div>
{renderTree(node.children, path, level + 1)}
</li>
);
} 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' />
<span>{node.title}</span>
</label>
</li>
);
}
})}
</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>
);
};
export default CascaderMultiSelect;

View File

@@ -0,0 +1,52 @@
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';
interface ClothingCardProps {
imageUrl: string;
onRemove: () => void;
scenesOptions?: any[];
scenes?: string[][];
onScenesChange?: (scenes: string[][]) => void;
fileName?: string;
}
const ClothingCard: React.FC<ClothingCardProps> = ({ imageUrl, onRemove, scenesOptions = [], scenes = [], onScenesChange, fileName }) => {
return (
<Card className='relative md:flex-row gap-6 group hover:shadow-lg transition-shadow'>
<CardHeader className='p-0'>
<CardAction>
<Button
type='button'
size='icon'
variant='destructive'
onClick={onRemove}
className='absolute -top-3 -right-2 w-6 h-6 p-0 rounded-full shadow transition-colors opacity-0 group-hover:opacity-100'
title='删除'
>
<X size={18} />
</Button>
</CardAction>
</CardHeader>
<CardContent className='flex items-center w-full gap-6'>
<div className='flex flex-col items-center'>
<img src={imageUrl} alt='预览' className='w-[200px] aspect-[9/16] object-cover rounded border max-h-72' />
{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'>
<label className='w-16 text-right flex-shrink-0'></label>
<div className='flex-1'>
<CascaderMultiSelect options={scenesOptions} value={scenes || []} onChange={onScenesChange || (() => {})} />
</div>
</div>
</div>
</CardContent>
</Card>
);
};
export default ClothingCard;

View File

@@ -0,0 +1,188 @@
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { api } from '@/lib/api';
import { zodResolver } from '@hookform/resolvers/zod';
import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import ClothingCard from './components/ClothingCard';
import type { Body_local_cloud_async_change_bg_api_v2_cloud_batch_change_bg_post } from '@/api';
// 五级联动的 tag schema
const tagSchema = z.object({
scenes: z.array(z.array(z.string())).optional(),
});
const formSchema = z.object({
clothing_images: z.array(z.instanceof(File)).min(1, '请上传至少一张服装图片').max(5, '最多上传5个商品'),
tags: z.array(tagSchema),
changeBg: z.boolean().optional(),
});
interface TagForm {
scenes?: string[][];
}
interface FormValues {
clothing_images: File[];
tags: TagForm[];
changeBg?: boolean;
}
const defaultValues: FormValues = {
clothing_images: [],
tags: [],
changeBg: false,
};
const TryOnPage: React.FC = () => {
const [scenesOptions, setScenesOptions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
// 表单
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
});
// 拉取标签数据
useEffect(() => {
api.Service.fetchStyleAvailableTagsApiTagStyleTagListGet().then(data => setScenesOptions(data.data || []));
}, []);
const tags = form.watch('tags');
// 图片上传和标签组同步append 新卡片)
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const oldFiles = form.getValues('clothing_images');
if (oldFiles.length + files.length > 5) {
alert('最多只能上传5个商品');
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
// 追加到已有图片和标签
const oldTags = form.getValues('tags');
const newFiles = [...oldFiles, ...files];
const newTags = [...oldTags, ...files.map(() => ({ scenes: [] as string[][] }))];
form.setValue('clothing_images', newFiles);
setImagePreviews(newFiles.map(file => URL.createObjectURL(file)));
form.setValue('tags', newTags);
// 清空 input 以便连续选择同一图片
if (fileInputRef.current) fileInputRef.current.value = '';
};
// 删除图片及对应标签组
const handleRemoveImage = (idx: number) => {
const files = form.getValues('clothing_images');
const tags = form.getValues('tags');
const newFiles = files.filter((_, i) => i !== idx);
const newTags = tags.filter((_, i) => i !== idx);
form.setValue('clothing_images', newFiles);
setImagePreviews(newFiles.map(file => URL.createObjectURL(file)));
form.setValue('tags', newTags);
};
// 场景多选
const handleScenesChange = (idx: number, scenes: string[][]) => {
const newTags = [...form.getValues('tags')];
newTags[idx] = { ...newTags[idx], scenes };
form.setValue('tags', newTags);
};
// 递归查找 prompt
function findPromptByPath(tree: any[], path: string[]): string {
let node: any = null;
let nodes: any[] = tree;
for (const t of path) {
node = nodes.find((n: any) => n.title === t);
if (!node) return '';
nodes = node.children || [];
}
return node && node.prompt ? node.prompt : '';
}
// 提交
const onSubmit = async (values: FormValues) => {
setLoading(true);
setResult(null);
const results: any[] = [];
console.log(values);
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.DefaultService.localCloudAsyncChangeBgApiV2CloudBatchChangeBgPost({ formData });
results.push(res);
}
}
setResult(results);
} catch (e: any) {
setResult({ error: e?.message || '请求失败' });
} finally {
setLoading(false);
}
};
const clothing_images = form.watch('clothing_images');
return (
<div className='p-6 max-w-4xl mx-auto'>
<h1 className='text-2xl font-bold mb-6'></h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
{/* 卡片式图片+表单 */}
<div className='flex flex-col gap-8'>
{clothing_images &&
clothing_images.length > 0 &&
clothing_images.map((_, idx) => {
const tag: TagForm = tags[idx] || { scenes: [] };
const fileName = clothing_images[idx]?.name;
return (
<ClothingCard
key={idx}
imageUrl={imagePreviews[idx]}
onRemove={() => handleRemoveImage(idx)}
scenesOptions={scenesOptions}
scenes={tag.scenes || []}
onScenesChange={scenes => handleScenesChange(idx, scenes)}
fileName={fileName}
/>
);
})}
<Button type='button' onClick={() => fileInputRef.current?.click()} className='w-40 self-center' disabled={clothing_images.length >= 5}>
</Button>
<input ref={fileInputRef} type='file' accept='image/*' multiple className='hidden' onChange={handleImageChange} />
</div>
<Button type='submit' disabled={loading} className='w-full'>
{loading ? '提交中...' : '提交换背景'}
</Button>
</form>
</Form>
{/* 结果展示 */}
{result && (
<div className='mt-8'>
<h2 className='text-lg font-semibold mb-2'></h2>
<pre className='bg-muted p-4 rounded text-sm overflow-x-auto'>{JSON.stringify(result, null, 2)}</pre>
</div>
)}
</div>
);
};
export default TryOnPage;

View File

@@ -1,131 +0,0 @@
import React, { useState } from 'react';
interface CascaderNode {
title: string;
children?: CascaderNode[];
prompt?: string;
}
interface CascaderMultiSelectProps {
options: CascaderNode[];
value: string[][]; // 每个已选项为路径数组,如 ['户外', '公园', '公园1']
onChange: (value: string[][]) => void;
}
const getLeafPaths = (tree: CascaderNode[], prefix: string[] = []): string[][] => {
let result: string[][] = [];
for (const node of tree) {
const path = [...prefix, node.title];
if (node.children && node.children.length > 0) {
result = result.concat(getLeafPaths(node.children, path));
} else {
result.push(path);
}
}
return result;
};
const CascaderMultiSelect: React.FC<CascaderMultiSelectProps> = ({ options, value, onChange }) => {
const [open, setOpen] = useState(false);
const [activePath, setActivePath] = useState<string[]>([]);
// 当前级的 options
const getCurrentOptions = () => {
let nodes = options;
for (const t of activePath) {
const node = nodes.find(n => n.title === t);
if (!node || !node.children) return [];
nodes = node.children;
}
return nodes;
};
// 判断某路径是否已选
const isSelected = (path: string[]) => value.some(v => v.join('/') === path.join('/'));
// 选中叶子节点
const handleSelect = (path: string[]) => {
if (!isSelected(path)) {
onChange([...value, path]);
} else {
onChange(value.filter(v => v.join('/') !== path.join('/')));
}
};
// 删除 tag
const handleRemove = (path: string[]) => {
onChange(value.filter(v => v.join('/') !== path.join('/')));
};
// 渲染 tag
const renderTags = () => (
<div className='flex flex-wrap gap-2'>
{value.map(v => (
<span key={v.join('/')} className='bg-primary/10 text-primary px-2 py-1 rounded text-xs flex items-center gap-1'>
{v.join(' / ')}
<button type='button' className='ml-1 text-xs' onClick={() => handleRemove(v)}>
×
</button>
</span>
))}
</div>
);
// 渲染级联下拉
const renderCascader = () => {
const currentOptions = getCurrentOptions();
return (
<div className='absolute z-50 bg-white border rounded shadow p-2 mt-1 min-w-[200px]'>
{activePath.length > 0 && (
<div className='mb-2 flex gap-1 text-xs text-muted-foreground'>
{activePath.map((t, i) => (
<span key={i} className='cursor-pointer hover:underline' onClick={() => setActivePath(activePath.slice(0, i + 1))}>
{t}
</span>
))}
</div>
)}
<ul>
{currentOptions.map(opt => (
<li key={opt.title} className='flex items-center gap-2 py-1'>
{opt.children && opt.children.length > 0 ? (
<>
<span className='cursor-pointer hover:text-primary' onClick={() => setActivePath([...activePath, opt.title])}>
{opt.title}
</span>
</>
) : (
<label className='flex items-center gap-1 cursor-pointer'>
<input type='checkbox' checked={isSelected([...activePath, opt.title])} onChange={() => handleSelect([...activePath, opt.title])} />
<span>{opt.title}</span>
</label>
)}
</li>
))}
</ul>
</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);
setActivePath([]);
}}
>
</button>
</div>
{open && renderCascader()}
{open && <div className='fixed inset-0 z-40' onClick={() => setOpen(false)} />}
</div>
);
};
export default CascaderMultiSelect;

View File

@@ -1,9 +1,9 @@
import React from 'react';
import CascaderMultiSelect from '@/components/block/CascaderMultiSelect';
import { Button } from '@/components/ui/button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Card, CardHeader, CardAction, CardContent } from '@/components/ui/card';
import { Card, CardAction, CardContent, CardHeader } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { X } from 'lucide-react';
import CascaderMultiSelect from './CascaderMultiSelect';
import React from 'react';
interface Tag {
sex: string;
@@ -59,7 +59,7 @@ const ClothingCard: React.FC<ClothingCardProps> = ({
size='icon'
variant='destructive'
onClick={onRemove}
className='absolute -top-3 -right-2 w-6 h-6 p-0 rounded-full shadow transition-colors transition-opacity opacity-0 group-hover:opacity-100'
className='absolute -top-3 -right-2 w-6 h-6 p-0 rounded-full shadow transition-colors opacity-0 group-hover:opacity-100'
title='删除'
>
<X size={18} />

View File

@@ -12,6 +12,10 @@ import { ACTION_PROMPTS } from '@/lib/prompt';
import { toast } from 'sonner';
import { addVideoTasks } from '@/lib/indexeddb';
import type { BatchVideoTaskRequest } from '@/api';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
const PAGE_SIZE = 10;
@@ -196,7 +200,10 @@ const TryOnTasksPage: React.FC = () => {
data.data.map(item => (
<TableRow key={item.id}>
<TableCell>
<Checkbox checked={isRowSelected(item.id)} onCheckedChange={() => handleRowSelect(item)} />
<Checkbox
checked={isRowSelected(item.id)}
onCheckedChange={() => handleRowSelect({ id: item.id, img_url: item.img_url || '', task_id: item.task_id || '' })}
/>
</TableCell>
<TableCell>
{item.img_url ? (
@@ -217,7 +224,7 @@ const TryOnTasksPage: React.FC = () => {
<TableCell>{item.task_id}</TableCell>
<TableCell>{item.img_status || '-'}</TableCell>
<TableCell>{item.video_status || '-'}</TableCell>
<TableCell>{item.create_time || '-'}</TableCell>
<TableCell>{dayjs.utc(item.create_time).local().format('YYYY-MM-DD HH:mm:ss') || '-'}</TableCell>
</TableRow>
))
) : (

View File

@@ -6,6 +6,10 @@ import { api } from '@/lib/api';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
interface LocalVideoTask {
id: string;
@@ -225,7 +229,7 @@ const TryOnVideoTaskPage: React.FC = () => {
<TableCell>{task.id}</TableCell>
<TableCell>{task.job_id}</TableCell>
<TableCell>{task.prompt}</TableCell>
<TableCell>{task.create_time ? new Date(task.create_time).toLocaleString() : '-'}</TableCell>
<TableCell>{dayjs.utc(task.create_time).local().format('YYYY-MM-DD HH:mm:ss') || '-'}</TableCell>
</TableRow>
);
})}