fix: 修复问题

This commit is contained in:
2025-06-27 19:02:23 +08:00
parent 9b7f68b86f
commit 4d210760c9
10 changed files with 502 additions and 111 deletions

View File

@@ -5,6 +5,7 @@
"name": "web",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
@@ -18,10 +19,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.523.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-router-dom": "^7.6.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
@@ -170,6 +173,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -634,6 +639,8 @@
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"openapi-typescript": ["openapi-typescript@7.8.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.3", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.0.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-1EeVWmDzi16A+siQlo/SwSGIT7HwaFAVjvMA7/jG5HMLSnrUOzPL7uSTRZZa4v/LCRxHTApHKtNY6glApEoiUQ=="],
"openapi-typescript-codegen": ["openapi-typescript-codegen@0.27.0", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^10.1.0", "camelcase": "^6.3.0", "commander": "^11.1.0", "fs-extra": "^11.2.0", "handlebars": "^4.7.8" }, "bin": { "openapi": "bin/index.js" } }, "sha512-QyQEod/vuel3zfnTRC3GgmYsqLPSBzB2OL4ojMYjO9hJmfYW02T+7tbQWEnuqWdhh2KSOBf3L8h59vLStr6vwA=="],
@@ -704,6 +711,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"sonner": ["sonner@2.0.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],

View File

@@ -14,6 +14,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
@@ -27,10 +28,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.523.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-router-dom": "^7.6.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",

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 } from 'lucide-react';
import { Home, 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 TasksPage from './pages/TasksPage';
import TryOnTasksPage from './pages/TryOnTasksPage';
import TryOnVideoTaskPage from './pages/TryOnVideoTaskPage';
const menu = [
{ path: '/', label: '首页', icon: <Home 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' /> },
];
@@ -23,8 +25,8 @@ function App() {
<SidebarMenu>
{menu.map(item => (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton asChild isActive={location.pathname === item.path}>
<Link to={item.path} className='flex items-center'>
<SidebarMenuButton asChild isActive={location.pathname === item.path} className='h-12'>
<Link to={item.path}>
{item.icon}
<span>{item.label}</span>
</Link>
@@ -38,7 +40,8 @@ function App() {
<Routes>
<Route path='/' element={<TryOnPage />} />
<Route path='/models' element={<ModelPage />} />
<Route path='/tasks' element={<TasksPage />} />
<Route path='/tasks' element={<TryOnTasksPage />} />
<Route path='/video-tasks' element={<TryOnVideoTaskPage />} />
</Routes>
</main>
<ConfirmDialog />

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

40
src/lib/indexeddb.ts Normal file
View File

@@ -0,0 +1,40 @@
// 简单封装 IndexedDB 用于本地视频任务存储
const DB_NAME = 'outfit-video-tasks-db';
const STORE_NAME = 'video_tasks';
const DB_VERSION = 1;
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
});
}
export async function addVideoTasks(tasks: { id: string; img_url: string; prompt: string; create_time: string; task_id: string }[]) {
const db = await openDB();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
tasks.forEach(task => store.put(task));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getAllVideoTasks(): Promise<any[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}

12
src/lib/prompt.ts Normal file
View File

@@ -0,0 +1,12 @@
export const ACTION_PROMPTS = [
'女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面',
'女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动',
'时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上',
'自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。',
'女生两只手捏着拳头轻盈的左右摇摆跳舞动作幅度不大然后把手摊开放在胸口再做出像popping心脏跳动的动作左右身体都要非常协调',
'一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情',
'美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大',
'女人低头看向裤子向镜头展示身材一只手放在了头上做pose动作',
'美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大',
'美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情',
];

View File

@@ -1,106 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useApi } from '@/hooks/useApi';
import { api } from '@/lib/api';
import type { VideoDataPaginationRequest } from '@/api/models/VideoDataPaginationRequest';
import type { PaginationResponse } from '@/api/models/PaginationResponse';
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const PAGE_SIZE = 10;
const TasksPage: React.FC = () => {
const [page, setPage] = useState(1);
const [previewImg, setPreviewImg] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const r = useCallback(async (params: VideoDataPaginationRequest) => {
const res = await api.DefaultService.getImageDataListApiImageDataListPost({ requestBody: params });
return res;
}, []);
const { data, loading, error, execute } = useApi<PaginationResponse>(r, null);
useEffect(() => {
execute({ page, page_size: PAGE_SIZE });
}, [page, execute]);
const handlePrev = () => setPage(p => Math.max(1, p - 1));
const handleNext = () => setPage(p => p + 1);
return (
<div className='p-6'>
{/* 图片预览 Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent style={{ maxHeight: '80vh' }} className='flex items-center justify-center' showCloseButton={false}>
{previewImg && <img src={previewImg} alt='预览图片' className='object-contain rounded shadow-lg max-h-[calc(80vh-100px)]' />}
</DialogContent>
</Dialog>
<h2 className='text-xl font-bold mb-4'></h2>
{loading ? (
<Skeleton className='h-40 w-full mb-4' />
) : error ? (
<div className='text-red-500 mb-4'>{error}</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.length ? (
data.data.map(item => (
<TableRow key={item.id}>
<TableCell>
{item.img_url ? (
<img
src={item.img_url}
alt='img'
className='w-16 h-16 object-cover rounded cursor-pointer transition hover:scale-105'
onClick={() => {
setPreviewImg(item.img_url!);
setDialogOpen(true);
}}
/>
) : (
<span className='text-gray-400'></span>
)}
</TableCell>
<TableCell>{item.id}</TableCell>
<TableCell>{item.task_id}</TableCell>
<TableCell>{item.img_status || '-'}</TableCell>
<TableCell>{item.video_status || '-'}</TableCell>
<TableCell>{item.create_time || '-'}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className='text-center text-gray-400'>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
<div className='flex justify-between items-center mt-4'>
<Button variant='outline' onClick={handlePrev} disabled={page === 1 || loading}>
</Button>
<span> {page} </span>
<Button variant='outline' onClick={handleNext} disabled={loading || (data?.data && data.data.length < PAGE_SIZE)}>
</Button>
</div>
</div>
);
};
export default TasksPage;

View File

@@ -0,0 +1,230 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useApi } from '@/hooks/useApi';
import { api } from '@/lib/api';
import type { VideoDataPaginationRequest } from '@/api/models/VideoDataPaginationRequest';
import type { PaginationResponse } from '@/api/models/PaginationResponse';
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { ACTION_PROMPTS } from '@/lib/prompt';
import { Service } from '@/api/services/Service';
import { toast } from 'sonner';
import { addVideoTasks } from '@/lib/indexeddb';
const PAGE_SIZE = 10;
const TryOnTasksPage: React.FC = () => {
const [page, setPage] = useState(1);
const [previewImg, setPreviewImg] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<{ id: number; img_url: string }[]>([]);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedPrompts, setSelectedPrompts] = useState<string[]>([]);
const [creating, setCreating] = useState(false);
const r = useCallback(async (params: VideoDataPaginationRequest) => {
const res = await api.DefaultService.getImageDataListApiImageDataListPost({ requestBody: params });
return res;
}, []);
const { data, loading, error, execute } = useApi<PaginationResponse>(r, null);
useEffect(() => {
execute({ page, page_size: PAGE_SIZE });
}, [page, execute]);
const handlePrev = () => setPage(p => Math.max(1, p - 1));
const handleNext = () => setPage(p => p + 1);
// 判断某行是否被选中
const isRowSelected = (id: number) => selectedRows.some(row => row.id === id);
// 当前页所有可选行id
const currentPageIds = data?.data?.map(item => item.id) || [];
// 当前页是否全选
const isAllCurrentPageSelected = currentPageIds.length > 0 && currentPageIds.every(id => isRowSelected(id));
// 单行选择
const handleRowSelect = (item: { id: number; img_url?: string }) => {
setSelectedRows(prev => {
if (isRowSelected(item.id)) {
return prev.filter(row => row.id !== item.id);
} else {
return [...prev, { id: item.id, img_url: item.img_url || '' }];
}
});
};
// 当前页全选/取消
const handleSelectAllCurrentPage = () => {
if (isAllCurrentPageSelected) {
setSelectedRows(prev => prev.filter(row => !currentPageIds.includes(row.id)));
} else {
const toAdd = (data?.data || []).filter(item => !isRowSelected(item.id)).map(item => ({ id: item.id, img_url: item.img_url || '' }));
setSelectedRows(prev => [...prev, ...toAdd]);
}
};
// 创建视频任务提交
const handleCreateTask = async () => {
if (!selectedRows.length || !selectedPrompts.length) return;
setCreating(true);
try {
const tasks = selectedRows.flatMap(row => selectedPrompts.map(prompt => ({ prompt, img_url: row.img_url })));
const res = (await Service.submitVideoTaskApiJmSubmitTaskPost({ requestBody: { tasks } })) as {
status: boolean;
data: { success: { task_id: string; img_url: string }[]; failed: string[] };
msg: string;
};
// 只存储成功的任务
const successList = res?.data?.success || [];
// 用于 prompt 匹配
const promptMap = new Map();
tasks.forEach(t => {
if (!promptMap.has(t.img_url)) promptMap.set(t.img_url, []);
promptMap.get(t.img_url).push(t.prompt);
});
const localTasks = successList.map((item: any) => {
// 取出 prompt如有多 prompt依次取出
let prompt = '';
if (promptMap.has(item.img_url)) {
const arr = promptMap.get(item.img_url);
prompt = arr.shift();
if (arr.length === 0) promptMap.delete(item.img_url);
}
return {
id: `${item.task_id}`,
img_url: item.img_url,
prompt,
create_time: new Date().toISOString(),
task_id: item.task_id,
};
});
await addVideoTasks(localTasks);
toast.success('任务提交成功');
setCreateDialogOpen(false);
setSelectedPrompts([]);
setSelectedRows([]);
} catch (e: any) {
toast.error(e?.message || '任务提交失败');
} finally {
setCreating(false);
}
};
return (
<div className='p-6'>
{/* 创建视频任务弹窗 */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent style={{ maxWidth: 480 }}>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className='max-h-60 overflow-y-auto space-y-2 my-4'>
{ACTION_PROMPTS.map(prompt => (
<label key={prompt} className='flex items-center gap-2 cursor-pointer'>
<Checkbox
checked={selectedPrompts.includes(prompt)}
onCheckedChange={() => {
setSelectedPrompts(prev => (prev.includes(prompt) ? prev.filter(p => p !== prompt) : [...prev, prompt]));
}}
/>
<span className='text-sm'>{prompt}</span>
</label>
))}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setCreateDialogOpen(false)} disabled={creating}>
</Button>
<Button onClick={handleCreateTask} disabled={creating || !selectedPrompts.length || !selectedRows.length}>
{creating ? '提交中...' : '提交'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 图片预览 Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent style={{ maxHeight: '80vh' }} className='flex items-center justify-center' showCloseButton={false}>
{previewImg && <img src={previewImg} alt='预览图片' className='object-contain rounded shadow-lg max-h-[calc(80vh-100px)]' />}
</DialogContent>
</Dialog>
<div className='flex justify-between items-center mb-4'>
<h2 className='text-xl font-bold'></h2>
<Button onClick={() => setCreateDialogOpen(true)} disabled={!selectedRows.length}>
</Button>
</div>
{loading ? (
<Skeleton className='h-40 w-full mb-4' />
) : error ? (
<div className='text-red-500 mb-4'>{error}</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Checkbox checked={isAllCurrentPageSelected} onCheckedChange={handleSelectAllCurrentPage} />
</TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.length ? (
data.data.map(item => (
<TableRow key={item.id}>
<TableCell>
<Checkbox checked={isRowSelected(item.id)} onCheckedChange={() => handleRowSelect(item)} />
</TableCell>
<TableCell>
{item.img_url ? (
<img
src={item.img_url}
alt='img'
className='w-16 h-16 object-cover rounded cursor-pointer transition hover:scale-105'
onClick={() => {
setPreviewImg(item.img_url!);
setDialogOpen(true);
}}
/>
) : (
<span className='text-gray-400'></span>
)}
</TableCell>
<TableCell>{item.id}</TableCell>
<TableCell>{item.task_id}</TableCell>
<TableCell>{item.img_status || '-'}</TableCell>
<TableCell>{item.video_status || '-'}</TableCell>
<TableCell>{item.create_time || '-'}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className='text-center text-gray-400'>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
<div className='flex justify-between items-center mt-4'>
<Button variant='outline' onClick={handlePrev} disabled={page === 1 || loading}>
</Button>
<span> {page} </span>
<Button variant='outline' onClick={handleNext} disabled={loading || (data?.data && data.data.length < PAGE_SIZE)}>
</Button>
</div>
</div>
);
};
export default TryOnTasksPage;

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react';
import { getAllVideoTasks } from '@/lib/indexeddb';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
interface LocalVideoTask {
id: string;
img_url: string;
prompt: string;
create_time: string;
task_id: string;
}
interface TaskStatusMap {
[job_id: string]: {
status: 'finished' | 'running' | 'failed' | 'unknown';
video_url?: string;
};
}
const TryOnVideoTaskPage: React.FC = () => {
const [tasks, setTasks] = useState<LocalVideoTask[]>([]);
const [statusMap, setStatusMap] = useState<TaskStatusMap>({});
const [previewImg, setPreviewImg] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [statusLoading, setStatusLoading] = useState(false);
// 1. 读取本地任务
useEffect(() => {
setLoading(true);
getAllVideoTasks()
.then(res => setTasks(res))
.finally(() => setLoading(false));
}, []);
// 2. 查询后端任务状态
useEffect(() => {
if (!tasks.length) return;
const job_ids = tasks.map(t => t.task_id).filter(Boolean);
if (!job_ids.length) return;
setStatusLoading(true);
api.Service.batchQueryVideoStatusApiJmBatchQueryStatusPost({ requestBody: { job_ids } })
.then(res => {
// 后端返回格式:{ data: { finished: [{job_id, video_url}], running: [job_id], failed: [job_id] }, ... }
const map: TaskStatusMap = {};
if (res?.data) {
(res.data.finished || []).forEach((item: any) => {
map[item.job_id] = { status: 'finished', video_url: item.video_url };
});
(res.data.running || []).forEach((job_id: string) => {
map[job_id] = { status: 'running' };
});
(res.data.failed || []).forEach((job_id: string) => {
map[job_id] = { status: 'failed' };
});
}
setStatusMap(map);
})
.finally(() => setStatusLoading(false));
}, [tasks]);
const renderStatus = (task: LocalVideoTask) => {
const s = statusMap[task.task_id];
if (!s || !['finished', 'running', 'failed'].includes(s.status)) {
return <span className='text-gray-400'></span>;
}
if (s.status === 'finished') {
return s.video_url ? (
<a href={s.video_url} target='_blank' rel='noopener noreferrer' className='text-green-600 underline'>
</a>
) : (
'已完成'
);
}
if (s.status === 'running') return '生成中';
if (s.status === 'failed') return <span className='text-red-500'></span>;
return <span className='text-gray-400'></span>;
};
return (
<div className='p-6'>
<h2 className='text-xl font-bold mb-4'></h2>
<div className='mb-6 flex justify-end'>
<Button variant='outline' onClick={() => window.location.reload()}>
</Button>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent style={{ maxHeight: '80vh' }} className='flex items-center justify-center' showCloseButton={false}>
{previewImg && <img src={previewImg} alt='预览图片' className='object-contain rounded shadow-lg max-h-[calc(80vh-100px)]' />}
</DialogContent>
</Dialog>
{loading ? (
<div>...</div>
) : tasks.length === 0 ? (
<div className='text-gray-400 text-center mt-20'></div>
) : (
<div className='overflow-x-auto'>
<Table className='min-w-full text-sm'>
<TableHeader>
<TableRow className='bg-gray-100'>
<TableHead className='p-2 text-center'></TableHead>
<TableHead className='p-2'>ID</TableHead>
<TableHead className='p-2'>ID</TableHead>
<TableHead className='p-2'>Prompt</TableHead>
<TableHead className='p-2'></TableHead>
<TableHead className='p-2'></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map(task => (
<TableRow key={task.id}>
<TableCell className='p-2 text-center'>
{task.img_url ? (
<img
src={task.img_url}
alt='img'
className='w-16 h-16 object-cover rounded cursor-pointer transition hover:scale-105 inline-block'
onClick={() => {
setPreviewImg(task.img_url);
setDialogOpen(true);
}}
/>
) : (
<span className='text-gray-400'></span>
)}
</TableCell>
<TableCell>{task.id}</TableCell>
<TableCell>{task.task_id}</TableCell>
<TableCell>{task.prompt}</TableCell>
<TableCell>{task.create_time ? new Date(task.create_time).toLocaleString() : '-'}</TableCell>
<TableCell>{statusLoading ? '查询中...' : renderStatus(task)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
};
export default TryOnVideoTaskPage;