Files
glam-web/src/pages/TryOnPage/index.tsx
2025-06-27 19:11:40 +08:00

242 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;