feat: 增加换背景
This commit is contained in:
3915
package-lock.json
generated
Normal file
3915
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
119
src/components/block/CascaderMultiSelect.tsx
Normal file
119
src/components/block/CascaderMultiSelect.tsx
Normal 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;
|
||||
52
src/pages/ChangeBgPage/components/ClothingCard.tsx
Normal file
52
src/pages/ChangeBgPage/components/ClothingCard.tsx
Normal 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;
|
||||
188
src/pages/ChangeBgPage/index.tsx
Normal file
188
src/pages/ChangeBgPage/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user