+ {activePath.length > 0 && (
+
+ {activePath.map((t, i) => (
+ setActivePath(activePath.slice(0, i + 1))}>
+ {t}
+
+ ))}
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+
+ {renderTags()}
+
+
+ {open && renderCascader()}
+ {open &&
setOpen(false)} />}
+
+ );
+};
+
+export default CascaderMultiSelect;
diff --git a/src/pages/TryOnPage/components/ClothingCard.tsx b/src/pages/TryOnPage/components/ClothingCard.tsx
new file mode 100644
index 0000000..2a412f8
--- /dev/null
+++ b/src/pages/TryOnPage/components/ClothingCard.tsx
@@ -0,0 +1,116 @@
+import React from 'react';
+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 { X } from 'lucide-react';
+import CascaderMultiSelect from './CascaderMultiSelect';
+
+interface Tag {
+ sex: string;
+ category: string;
+ size: string;
+ material: string;
+ color: string;
+}
+
+interface TagOptions {
+ sexOptions: any[];
+ categoryOptions: any[];
+ sizeOptions: any[];
+ materialOptions: any[];
+ colorOptions: any[];
+}
+
+interface ClothingCardProps {
+ imageUrl: string;
+ tag: Tag;
+ options: TagOptions;
+ idx: number;
+ onTagChange: (level: keyof Tag, value: string) => void;
+ onRemove: () => void;
+ errors?: Partial
>;
+ scenesOptions?: any[];
+ scenes?: string[][];
+ onScenesChange?: (scenes: string[][]) => void;
+ fileName?: string;
+}
+
+const LABELS = ['性别', '类别', '尺寸', '材质', '颜色'];
+const LEVELS: (keyof Tag)[] = ['sex', 'category', 'size', 'material', 'color'];
+
+const ClothingCard: React.FC = ({
+ imageUrl,
+ tag,
+ options,
+ onTagChange,
+ onRemove,
+ errors = {},
+ scenesOptions = [],
+ scenes = [],
+ onScenesChange,
+ fileName,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+

+ {fileName &&
{fileName}
}
+
+
+ {LEVELS.map((level, lidx) => {
+ let selectOptions: any[] = [];
+ if (level === 'sex') selectOptions = options.sexOptions;
+ else if (level === 'category') selectOptions = options.categoryOptions;
+ else if (level === 'size') selectOptions = options.sizeOptions;
+ else if (level === 'material') selectOptions = options.materialOptions;
+ else if (level === 'color') selectOptions = options.colorOptions;
+ return (
+
+
+
+
+ {errors[level] &&
{errors[level]}
}
+
+
+ );
+ })}
+ {/* 场景级联多选 */}
+
+
+
+
+ );
+};
+
+export default ClothingCard;
diff --git a/src/pages/TryOnPage/index.tsx b/src/pages/TryOnPage/index.tsx
new file mode 100644
index 0000000..735aaf8
--- /dev/null
+++ b/src/pages/TryOnPage/index.tsx
@@ -0,0 +1,241 @@
+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([]);
+ const [scenesOptions, setScenesOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const fileInputRef = useRef(null);
+ const [imagePreviews, setImagePreviews] = useState([]);
+
+ // 表单
+ const form = useForm({
+ 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) => {
+ 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 (
+
+
换装体验
+
+
+ {/* 结果展示 */}
+ {result && (
+
+
结果
+
{JSON.stringify(result, null, 2)}
+
+ )}
+
+ );
+};
+
+export default TryOnPage;