fix: 修复问题
This commit is contained in:
9
bun.lock
9
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
src/App.tsx
13
src/App.tsx
@@ -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 />
|
||||
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
23
src/components/ui/sonner.tsx
Normal file
23
src/components/ui/sonner.tsx
Normal 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
40
src/lib/indexeddb.ts
Normal 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
12
src/lib/prompt.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const ACTION_PROMPTS = [
|
||||
'女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面',
|
||||
'女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动',
|
||||
'时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上',
|
||||
'自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。',
|
||||
'女生两只手捏着拳头轻盈的左右摇摆跳舞,动作幅度不大,然后把手摊开放在胸口再做出像popping心脏跳动的动作,左右身体都要非常协调',
|
||||
'一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情',
|
||||
'美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大',
|
||||
'女人低头看向裤子,向镜头展示身材,一只手放在了头上做pose动作',
|
||||
'美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大',
|
||||
'美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情',
|
||||
];
|
||||
@@ -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;
|
||||
230
src/pages/TryOnTasksPage/index.tsx
Normal file
230
src/pages/TryOnTasksPage/index.tsx
Normal 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;
|
||||
147
src/pages/TryOnVideoTaskPage/index.tsx
Normal file
147
src/pages/TryOnVideoTaskPage/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user