242 lines
9.7 KiB
TypeScript
242 lines
9.7 KiB
TypeScript
import type { Body_local_async_gen_images_api_v2_local_batch_edit_images_post } from '@/api/models/Body_local_async_gen_images_api_v2_local_batch_edit_images_post';
|
||
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';
|
||
|
||
// 五级联动的 tag schema
|
||
const tagSchema = z.object({
|
||
sex: z.string().min(1, '请选择性别'),
|
||
category: z.string().min(1, '请选择类别'),
|
||
size: z.string().min(1, '请选择尺寸'),
|
||
material: z.string().min(1, '请选择材质'),
|
||
color: z.string().min(1, '请选择颜色'),
|
||
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 {
|
||
sex: string;
|
||
category: string;
|
||
size: string;
|
||
material: string;
|
||
color: string;
|
||
scenes?: string[][];
|
||
}
|
||
|
||
interface FormValues {
|
||
clothing_images: File[];
|
||
tags: TagForm[];
|
||
changeBg?: boolean;
|
||
}
|
||
|
||
const defaultValues: FormValues = {
|
||
clothing_images: [],
|
||
tags: [],
|
||
changeBg: false,
|
||
};
|
||
|
||
const TryOnPage: React.FC = () => {
|
||
const [tagOptions, setTagOptions] = useState<any[]>([]);
|
||
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.fetchModelTagsApiTagModelTagListGet().then(data => setTagOptions(data.data || []));
|
||
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(() => ({ sex: '', category: '', size: '', material: '', color: '', 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 handleCascade = (idx: number, level: keyof TagForm, value: string) => {
|
||
const newTags = [...form.getValues('tags')];
|
||
if (level === 'sex') {
|
||
newTags[idx] = { sex: value, category: '', size: '', material: '', color: '', scenes: newTags[idx]?.scenes || [] };
|
||
} else if (level === 'category') {
|
||
newTags[idx] = { ...newTags[idx], category: value, size: '', material: '', color: '', scenes: newTags[idx]?.scenes || [] };
|
||
} else if (level === 'size') {
|
||
newTags[idx] = { ...newTags[idx], size: value, material: '', color: '', scenes: newTags[idx]?.scenes || [] };
|
||
} else if (level === 'material') {
|
||
newTags[idx] = { ...newTags[idx], material: value, scenes: newTags[idx]?.scenes || [] };
|
||
} else if (level === 'color') {
|
||
newTags[idx] = { ...newTags[idx], color: value, scenes: newTags[idx]?.scenes || [] };
|
||
}
|
||
form.setValue('tags', newTags);
|
||
};
|
||
|
||
// 场景多选
|
||
const handleScenesChange = (idx: number, scenes: string[][]) => {
|
||
const newTags = [...form.getValues('tags')];
|
||
newTags[idx] = { ...newTags[idx], scenes };
|
||
form.setValue('tags', newTags);
|
||
};
|
||
|
||
// 计算每个卡片的options
|
||
const getTagOptions = (idx: number) => {
|
||
const tag = tags[idx] || {};
|
||
// 性别
|
||
const sexOptions = tagOptions;
|
||
// 类别
|
||
const categoryOptions = tag.sex ? tagOptions.find((t: any) => t.title === tag.sex)?.children || [] : [];
|
||
// 尺寸
|
||
const sizeOptions = tag.category ? categoryOptions.find((t: any) => t.title === tag.category)?.children || [] : [];
|
||
// 材质
|
||
const materialOptions = tag.size
|
||
? sizeOptions.find((t: any) => t.title === tag.size)?.children?.find((c: any) => c.title === '材质')?.children || []
|
||
: [];
|
||
// 颜色
|
||
const colorOptions = tag.size
|
||
? sizeOptions.find((t: any) => t.title === tag.size)?.children?.find((c: any) => c.title === '颜色')?.children || []
|
||
: [];
|
||
return { sexOptions, categoryOptions, sizeOptions, materialOptions, colorOptions };
|
||
};
|
||
|
||
// 递归查找 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[] = [];
|
||
try {
|
||
for (let i = 0; i < values.clothing_images.length; i++) {
|
||
const tag = values.tags[i];
|
||
const scenesArr: string[][] = tag.scenes || [];
|
||
const scenes_list = scenesArr.map((path: string[]) => ({
|
||
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(scenes_list.map(s => ({ title: s.title, prompt: s.prompt }))),
|
||
mode: 'both',
|
||
};
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const res = await api.DefaultService.localAsyncGenImagesApiV2LocalBatchEditImagesPost({ 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] || { sex: '', category: '', size: '', material: '', color: '', scenes: [] };
|
||
const { sexOptions, categoryOptions, sizeOptions, materialOptions, colorOptions } = getTagOptions(idx);
|
||
const fileName = clothing_images[idx]?.name;
|
||
return (
|
||
<ClothingCard
|
||
key={idx}
|
||
imageUrl={imagePreviews[idx]}
|
||
tag={tag}
|
||
options={{ sexOptions, categoryOptions, sizeOptions, materialOptions, colorOptions }}
|
||
idx={idx}
|
||
onTagChange={(level, value) => handleCascade(idx, level, value)}
|
||
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;
|