AIGeneration.jsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. import React, { useState, useEffect, useMemo } from 'react';
  2. import { Button } from '@/components/ui/button';
  3. import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
  4. import { Label } from '@/components/ui/label';
  5. import { Input } from '@/components/ui/input';
  6. import { Badge } from '@/components/ui/badge';
  7. import { Alert, AlertDescription } from '@/components/ui/alert';
  8. import { Progress } from '@/components/ui/progress';
  9. import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
  10. import { ScrollArea } from '@/components/ui/scroll-area';
  11. import { Separator } from '@/components/ui/separator';
  12. import {
  13. User,
  14. Play,
  15. Loader2,
  16. CheckCircle,
  17. Wand2,
  18. Sparkles,
  19. Image as ImageIcon,
  20. FileText,
  21. Zap,
  22. Save,
  23. Upload,
  24. Plus,
  25. Settings,
  26. Palette,
  27. Camera,
  28. Shirt,
  29. ImageIcon as ImageIcon2
  30. } from 'lucide-react';
  31. import { motion, AnimatePresence } from 'framer-motion';
  32. import { materialLibraryAPI } from '@/api/materials';
  33. import { textTemplateAPI } from '@/api/textTemplates';
  34. import { aiSwapAPI } from '@/api/ai_swap';
  35. import { aiSwapBgAPI } from '@/api/ai_swap_bg';
  36. import { useAuth } from '@/contexts/AuthContext';
  37. // 素材选择卡片组件
  38. const MaterialCard = ({ item, isSelected, onSelect, type = 'image' }) => (
  39. <motion.div
  40. whileHover={{ scale: 1.02 }}
  41. className={`relative aspect-square bg-slate-100 rounded-lg overflow-hidden cursor-pointer transition-all ${
  42. isSelected ? 'ring-2 ring-blue-500 shadow-lg' : 'hover:ring-2 hover:ring-slate-300'
  43. }`}
  44. onClick={() => onSelect(item.id)}
  45. >
  46. {item.file_url ? (
  47. <img
  48. src={item.file_url}
  49. alt={item.original_filename}
  50. className="w-full h-full object-cover"
  51. />
  52. ) : (
  53. <div className="w-full h-full flex flex-col items-center justify-center text-slate-400">
  54. <ImageIcon className="w-8 h-8 mb-2" />
  55. <span className="text-xs">暂无图片</span>
  56. </div>
  57. )}
  58. {isSelected && (
  59. <div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
  60. <CheckCircle className="w-4 h-4 text-white" />
  61. </div>
  62. )}
  63. <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
  64. <p className="text-white text-xs font-medium truncate">
  65. {item.original_filename}
  66. </p>
  67. </div>
  68. </motion.div>
  69. );
  70. // 模板选择组件
  71. const TemplateItem = ({ template, isSelected, onSelect, type }) => (
  72. <motion.div
  73. whileHover={{ scale: 1.01 }}
  74. className={`p-3 rounded-lg border cursor-pointer transition-all ${
  75. isSelected ? 'border-blue-500 bg-blue-50' : 'border-slate-200 hover:border-slate-300'
  76. }`}
  77. onClick={() => onSelect(template.id)}
  78. >
  79. <div className="flex items-center justify-between mb-2">
  80. <span className="text-sm font-medium">{template.name}</span>
  81. {isSelected && <CheckCircle className="w-4 h-4 text-blue-500" />}
  82. </div>
  83. <Badge variant="outline" className="text-xs">
  84. {template.tag || type}
  85. </Badge>
  86. </motion.div>
  87. );
  88. export default function AIGeneration() {
  89. const { user } = useAuth();
  90. // 状态管理
  91. const [originalImages, setOriginalImages] = useState([]);
  92. const [ipCharacters, setIpCharacters] = useState([]);
  93. const [clothingItems, setClothingItems] = useState([]);
  94. const [sceneTemplates, setSceneTemplates] = useState([]);
  95. const [copyTemplates, setCopyTemplates] = useState([]);
  96. const [selectedOriginalImages, setSelectedOriginalImages] = useState([]);
  97. const [selectedIpCharacters, setSelectedIpCharacters] = useState([]);
  98. const [selectedClothingItems, setSelectedClothingItems] = useState([]);
  99. const [selectedSceneTemplates, setSelectedSceneTemplates] = useState([]);
  100. const [selectedCopyTemplates, setSelectedCopyTemplates] = useState([]);
  101. const [taskName, setTaskName] = useState('');
  102. const [selectedTaskTypes, setSelectedTaskTypes] = useState([]);
  103. const [quantityPerGroup, setQuantityPerGroup] = useState(1);
  104. const [isGenerating, setIsGenerating] = useState(false);
  105. const [generationProgress, setGenerationProgress] = useState(0);
  106. const [currentTask, setCurrentTask] = useState('');
  107. const [message, setMessage] = useState(null);
  108. const [loading, setLoading] = useState(false);
  109. const [currentTaskId, setCurrentTaskId] = useState(null); // 兼容保留,展示第一个任务ID
  110. const [currentTaskIds, setCurrentTaskIds] = useState([]); // 多任务ID
  111. const [taskStatus, setTaskStatus] = useState(null); // 聚合状态摘要
  112. const [pollingInterval, setPollingInterval] = useState(null);
  113. useEffect(() => {
  114. if (user) {
  115. loadMaterials();
  116. loadTemplates();
  117. }
  118. }, [user]);
  119. // 清理轮询定时器
  120. useEffect(() => {
  121. return () => {
  122. if (pollingInterval) {
  123. clearInterval(pollingInterval);
  124. }
  125. };
  126. }, [pollingInterval]);
  127. const loadMaterials = async () => {
  128. // 使用当前登录用户的ID
  129. const userId = user?.id;
  130. if (!userId) {
  131. setMessage({ type: 'error', text: '请先登录' });
  132. return;
  133. }
  134. setLoading(true);
  135. try {
  136. // 获取原始素材(原图)- 对应素材库的"原始素材"标签页
  137. const originalResponse = await materialLibraryAPI.getMaterials(userId, 'original');
  138. console.log('原始素材API响应:', originalResponse);
  139. if (originalResponse.success) {
  140. const originalData = originalResponse.images.map(item => ({
  141. ...item,
  142. file_url: getImageUrl(item)
  143. }));
  144. setOriginalImages(originalData);
  145. console.log('原始素材数据:', originalData);
  146. }
  147. // 获取IP素材(人脸)- 对应素材库的"IP素材"标签页
  148. const faceResponse = await materialLibraryAPI.getMaterials(userId, 'face');
  149. console.log('IP素材API响应:', faceResponse);
  150. if (faceResponse.success) {
  151. const faceData = faceResponse.images.map(item => ({
  152. ...item,
  153. file_url: getImageUrl(item)
  154. }));
  155. setIpCharacters(faceData);
  156. console.log('IP素材数据:', faceData);
  157. }
  158. // 获取产品素材(服装)- 对应素材库的"产品素材"标签页,但API类型是'cloth'
  159. const clothResponse = await materialLibraryAPI.getMaterials(userId, 'cloth');
  160. console.log('产品素材API响应:', clothResponse);
  161. if (clothResponse.success) {
  162. const clothData = clothResponse.images.map(item => ({
  163. ...item,
  164. file_url: getImageUrl(item)
  165. }));
  166. setClothingItems(clothData);
  167. console.log('产品素材数据:', clothData);
  168. }
  169. } catch (error) {
  170. console.error('加载素材失败:', error);
  171. setMessage({ type: 'error', text: '加载素材失败,请稍后重试' });
  172. } finally {
  173. setLoading(false);
  174. }
  175. };
  176. // 获取图片URL
  177. const getImageUrl = (item) => {
  178. if (!item.stored_path) return '';
  179. const filename = item.stored_path.split(/[\\/]/).pop();
  180. const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
  181. return `${baseURL}/materials/${filename}`;
  182. };
  183. const loadTemplates = async () => {
  184. const userId = user?.id;
  185. if (!userId) {
  186. setMessage({ type: 'error', text: '请先登录' });
  187. return;
  188. }
  189. try {
  190. const [sceneResp, copyResp] = await Promise.all([
  191. textTemplateAPI.getTextTemplates(userId, 'prompt', 1, 100),
  192. textTemplateAPI.getTextTemplates(userId, 'copywrite', 1, 100),
  193. ]);
  194. const mapTemplates = (resp) => (resp && resp.success && Array.isArray(resp.templates)
  195. ? resp.templates.map(t => ({
  196. id: t.id,
  197. name: t.text_name,
  198. tag: t.text_label,
  199. created_date: t.created_at,
  200. content: t.text_content,
  201. }))
  202. : []);
  203. setSceneTemplates(mapTemplates(sceneResp));
  204. setCopyTemplates(mapTemplates(copyResp));
  205. } catch (error) {
  206. console.error('加载模板失败:', error);
  207. setMessage({ type: 'error', text: '加载模板失败,请稍后重试' });
  208. }
  209. };
  210. // 轮询多个任务状态并聚合进度
  211. const pollAllTasksStatus = async (taskIds) => {
  212. try {
  213. if (!taskIds || taskIds.length === 0) return;
  214. const responses = await Promise.allSettled(taskIds.map(id => aiSwapAPI.getTaskStatus(id)));
  215. const fulfilled = responses
  216. .filter(r => r.status === 'fulfilled')
  217. .map(r => r.value);
  218. const rejected = responses.filter(r => r.status === 'rejected');
  219. const total = taskIds.length;
  220. const completed = fulfilled.filter(r => r.status === 'completed').length;
  221. const failed = fulfilled.filter(r => r.status === 'failed').length + rejected.length;
  222. const processing = fulfilled.filter(r => r.status === 'processing').length;
  223. const pending = total - completed - failed - processing;
  224. // 聚合进度(平均)
  225. const avgProgress = fulfilled.length > 0
  226. ? Math.round(
  227. fulfilled.reduce((sum, r) => sum + (typeof r.progress === 'number' ? r.progress : 0), 0) / fulfilled.length
  228. )
  229. : 0;
  230. setGenerationProgress(avgProgress);
  231. setTaskStatus({
  232. total,
  233. completed,
  234. failed,
  235. processing,
  236. pending,
  237. details: fulfilled
  238. });
  239. if (completed + failed === total) {
  240. setCurrentTask('生成完成');
  241. setMessage({ type: failed === 0 ? 'success' : 'error', text: failed === 0 ? '全部生成完成!' : `部分失败:成功 ${completed},失败 ${failed}` });
  242. setIsGenerating(false);
  243. if (pollingInterval) {
  244. clearInterval(pollingInterval);
  245. setPollingInterval(null);
  246. }
  247. } else if (processing > 0 || pending > 0) {
  248. setCurrentTask('AI处理中...');
  249. }
  250. } catch (error) {
  251. console.error('轮询任务状态失败:', error);
  252. setMessage({ type: 'error', text: '获取任务状态失败' });
  253. }
  254. };
  255. const handleSelection = (setter, selectedItems, id) => {
  256. setter(prev => {
  257. if (prev.includes(id)) {
  258. return prev.filter(item => item !== id);
  259. } else {
  260. return [...prev, id];
  261. }
  262. });
  263. };
  264. const handleTaskTypeToggle = (taskType) => {
  265. setSelectedTaskTypes(prev => {
  266. if (prev.includes(taskType)) {
  267. return prev.filter(type => type !== taskType);
  268. } else {
  269. return [...prev, taskType];
  270. }
  271. });
  272. };
  273. // 取消当前任务
  274. const handleCancelTask = async () => {
  275. if (!currentTaskIds || currentTaskIds.length === 0) return;
  276. try {
  277. await Promise.allSettled(currentTaskIds.map(id => aiSwapAPI.cancelTask(id)));
  278. setMessage({ type: 'success', text: '已取消全部任务' });
  279. setIsGenerating(false);
  280. setCurrentTaskId(null);
  281. setCurrentTaskIds([]);
  282. setTaskStatus(null);
  283. if (pollingInterval) {
  284. clearInterval(pollingInterval);
  285. setPollingInterval(null);
  286. }
  287. } catch (error) {
  288. console.error('取消任务失败:', error);
  289. setMessage({ type: 'error', text: '取消任务失败' });
  290. }
  291. };
  292. const handleGenerate = async () => {
  293. // 验证用户登录状态
  294. if (!user?.id) {
  295. setMessage({ type: 'error', text: '请先登录' });
  296. return;
  297. }
  298. // 验证选择条件
  299. if (selectedIpCharacters.length === 0) {
  300. setMessage({ type: 'error', text: '请选择至少一个IP形象' });
  301. return;
  302. }
  303. if (selectedClothingItems.length === 0) {
  304. setMessage({ type: 'error', text: '请选择至少一件服装' });
  305. return;
  306. }
  307. if (!selectedTaskTypes.includes('换脸') ||
  308. !selectedTaskTypes.includes('换衣')) {
  309. setMessage({ type: 'error', text: '请选择换脸和换衣任务类型' });
  310. return;
  311. }
  312. if (selectedSceneTemplates.length === 0) {
  313. setMessage({ type: 'error', text: '请选择至少一个场景模板' });
  314. return;
  315. }
  316. setIsGenerating(true);
  317. setGenerationProgress(0);
  318. setMessage(null);
  319. setCurrentTaskId(null);
  320. setTaskStatus(null);
  321. try {
  322. // 使用当前登录用户的ID
  323. const userId = user.id;
  324. // 组合选择,进行笛卡尔积
  325. const selectedFaces = ipCharacters.filter(item => selectedIpCharacters.includes(item.id));
  326. const selectedClothes = clothingItems.filter(item => selectedClothingItems.includes(item.id));
  327. const selectedScenes = sceneTemplates.filter(item => selectedSceneTemplates.includes(item.id));
  328. const selectedCopies = selectedCopyTemplates.length > 0
  329. ? copyTemplates.filter(item => selectedCopyTemplates.includes(item.id))
  330. : [null];
  331. if (selectedFaces.length === 0 || selectedClothes.length === 0 || selectedScenes.length === 0) {
  332. throw new Error('选中的素材/模板不存在');
  333. }
  334. const submittedTaskIds = [];
  335. const totalCombos = selectedFaces.length * selectedClothes.length * selectedScenes.length * selectedCopies.length;
  336. console.log(`即将提交组合任务数量: ${totalCombos}`);
  337. // 顺序提交,避免瞬时过载(也可改成并发 Promise.all)
  338. for (const face of selectedFaces) {
  339. for (const cloth of selectedClothes) {
  340. for (const scene of selectedScenes) {
  341. for (const copy of selectedCopies) {
  342. let prompt = 'AI换脸换装';
  343. if (scene) {
  344. prompt += `,场景:${scene.content}`;
  345. }
  346. if (copy) {
  347. prompt += `,文案风格:${copy.name}`;
  348. }
  349. if (taskName.trim()) {
  350. prompt += `,任务:${taskName.trim()}`;
  351. }
  352. const swapData = {
  353. user_id: userId,
  354. face_image_id: face.id,
  355. cloth_image_id: cloth.id,
  356. prompt,
  357. quantity: quantityPerGroup,
  358. };
  359. const resp = await aiSwapAPI.processSwap(swapData);
  360. if (resp && resp.success === true && resp.task_id) {
  361. submittedTaskIds.push(resp.task_id);
  362. } else {
  363. console.warn('组合任务提交失败:', resp);
  364. }
  365. }
  366. }
  367. }
  368. }
  369. if (submittedTaskIds.length > 0) {
  370. setCurrentTaskIds(submittedTaskIds);
  371. setCurrentTaskId(submittedTaskIds[0]);
  372. setMessage({ type: 'success', text: `已提交 ${submittedTaskIds.length}/${totalCombos} 个任务,处理中...` });
  373. setCurrentTask('准备素材');
  374. setGenerationProgress(5);
  375. // 开始轮询:聚合全部任务进度
  376. const interval = setInterval(() => {
  377. pollAllTasksStatus(submittedTaskIds);
  378. }, 2000);
  379. setPollingInterval(interval);
  380. // 立即轮询一次
  381. pollAllTasksStatus(submittedTaskIds);
  382. } else {
  383. setIsGenerating(false);
  384. setMessage({ type: 'error', text: '未能成功提交任何任务,请稍后重试' });
  385. }
  386. } catch (error) {
  387. setIsGenerating(false);
  388. console.error('换脸换装请求异常:', error);
  389. const errorMsg = error.message || '处理失败,请稍后重试';
  390. setMessage({ type: 'error', text: errorMsg });
  391. }
  392. };
  393. const canGenerate = selectedIpCharacters.length > 0 &&
  394. selectedClothingItems.length > 0 &&
  395. selectedTaskTypes.includes('换脸') &&
  396. selectedTaskTypes.includes('换衣') &&
  397. selectedSceneTemplates.length > 0 &&
  398. !isGenerating;
  399. const totalSelected = selectedIpCharacters.length + selectedClothingItems.length;
  400. return (
  401. <div className="min-h-screen bg-slate-50">
  402. {/* 顶部标题栏 */}
  403. <div className="bg-white border-b border-slate-200 px-6 py-4">
  404. <div className="max-w-7xl mx-auto">
  405. <div className="flex items-center justify-between">
  406. <div>
  407. <h1 className="text-2xl font-bold text-slate-900">智能生成</h1>
  408. <p className="text-slate-600 text-sm mt-1">
  409. 选择素材和模板,使用ComfyUI一键生成营销内容
  410. </p>
  411. </div>
  412. <Button
  413. onClick={handleGenerate}
  414. disabled={!canGenerate}
  415. size="lg"
  416. className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white px-8"
  417. >
  418. {isGenerating ? (
  419. <>
  420. <Loader2 className="w-5 h-5 mr-2 animate-spin" />
  421. 生成中...
  422. </>
  423. ) : (
  424. <>
  425. <Play className="w-5 h-5 mr-2" />
  426. 开始生成
  427. </>
  428. )}
  429. </Button>
  430. </div>
  431. </div>
  432. </div>
  433. {/* 消息提示 */}
  434. {message && (
  435. <div className="px-6 py-2">
  436. <Alert className={`${message.type === 'error' ? 'border-red-200 bg-red-50 text-red-700' : 'border-green-200 bg-green-50 text-green-700'}`}>
  437. <AlertDescription>{message.text}</AlertDescription>
  438. </Alert>
  439. </div>
  440. )}
  441. {/* 主要内容区域 */}
  442. <div className="max-w-7xl mx-auto px-6 py-6">
  443. <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
  444. {/* 第一栏:选择原图 */}
  445. <Card className="h-fit">
  446. <CardHeader className="pb-3">
  447. <CardTitle className="flex items-center gap-2 text-lg">
  448. <Camera className="w-5 h-5" />
  449. 选择原图
  450. <Badge variant="secondary" className="ml-auto">
  451. {selectedOriginalImages.length}
  452. </Badge>
  453. </CardTitle>
  454. </CardHeader>
  455. <CardContent>
  456. {loading ? (
  457. <div className="text-center py-8">
  458. <Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-slate-400" />
  459. <p className="text-sm text-slate-500">加载中...</p>
  460. </div>
  461. ) : originalImages.length > 0 ? (
  462. <div className="grid grid-cols-1 gap-4">
  463. {originalImages.map((image) => (
  464. <MaterialCard
  465. key={image.id}
  466. item={image}
  467. isSelected={selectedOriginalImages.includes(image.id)}
  468. onSelect={(id) => handleSelection(setSelectedOriginalImages, selectedOriginalImages, id)}
  469. />
  470. ))}
  471. </div>
  472. ) : (
  473. <div className="text-center py-8 text-slate-500">
  474. <ImageIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
  475. <p className="text-sm">暂无原图</p>
  476. <p className="text-xs mt-1">请先到素材库上传</p>
  477. </div>
  478. )}
  479. </CardContent>
  480. </Card>
  481. {/* 第二栏:选择IP形象 */}
  482. <Card className="h-fit">
  483. <CardHeader className="pb-3">
  484. <CardTitle className="flex items-center gap-2 text-lg">
  485. <User className="w-5 h-5" />
  486. 选择IP形象
  487. <Badge variant="secondary" className="ml-auto">
  488. {selectedIpCharacters.length}
  489. </Badge>
  490. </CardTitle>
  491. </CardHeader>
  492. <CardContent>
  493. {loading ? (
  494. <div className="text-center py-8">
  495. <Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-slate-400" />
  496. <p className="text-sm text-slate-500">加载中...</p>
  497. </div>
  498. ) : ipCharacters.length > 0 ? (
  499. <div className="grid grid-cols-1 gap-4">
  500. {ipCharacters.map((character) => (
  501. <MaterialCard
  502. key={character.id}
  503. item={character}
  504. isSelected={selectedIpCharacters.includes(character.id)}
  505. onSelect={(id) => handleSelection(setSelectedIpCharacters, selectedIpCharacters, id)}
  506. />
  507. ))}
  508. </div>
  509. ) : (
  510. <div className="text-center py-8 text-slate-500">
  511. <User className="w-12 h-12 mx-auto mb-3 text-slate-300" />
  512. <p className="text-sm">暂无IP形象</p>
  513. <p className="text-xs mt-1">请先到素材库上传</p>
  514. </div>
  515. )}
  516. </CardContent>
  517. </Card>
  518. {/* 第三栏:选择服装 */}
  519. <Card className="h-fit">
  520. <CardHeader className="pb-3">
  521. <CardTitle className="flex items-center gap-2 text-lg">
  522. <Shirt className="w-5 h-5" />
  523. 选择服装
  524. <Badge variant="secondary" className="ml-auto">
  525. {selectedClothingItems.length}
  526. </Badge>
  527. </CardTitle>
  528. </CardHeader>
  529. <CardContent>
  530. {loading ? (
  531. <div className="text-center py-8">
  532. <Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-slate-400" />
  533. <p className="text-sm text-slate-500">加载中...</p>
  534. </div>
  535. ) : clothingItems.length > 0 ? (
  536. <div className="grid grid-cols-1 gap-4">
  537. {clothingItems.map((clothing) => (
  538. <MaterialCard
  539. key={clothing.id}
  540. item={clothing}
  541. isSelected={selectedClothingItems.includes(clothing.id)}
  542. onSelect={(id) => handleSelection(setSelectedClothingItems, selectedClothingItems, id)}
  543. />
  544. ))}
  545. </div>
  546. ) : (
  547. <div className="text-center py-8 text-slate-500">
  548. <Shirt className="w-12 h-12 mx-auto mb-3 text-slate-300" />
  549. <p className="text-sm">暂无服装</p>
  550. <p className="text-xs mt-1">请先到素材库上传</p>
  551. </div>
  552. )}
  553. </CardContent>
  554. </Card>
  555. {/* 第四栏:生成配置 */}
  556. <Card className="h-fit" style={{color: 'black'}}>
  557. <CardHeader className="pb-3">
  558. <CardTitle className="flex items-center gap-2 text-lg">
  559. <Settings className="w-5 h-5" />
  560. 生成配置
  561. </CardTitle>
  562. </CardHeader>
  563. <CardContent className="space-y-6">
  564. {/* 任务命名 */}
  565. <div>
  566. <Label htmlFor="taskName" className="text-sm font-bold">任务命名</Label>
  567. <div className="relative mt-2">
  568. <Input
  569. id="taskName"
  570. value={taskName}
  571. onChange={(e) => setTaskName(e.target.value)}
  572. placeholder="输入任务名称..."
  573. className="pr-8"
  574. />
  575. <FileText className="w-4 h-4 absolute right-2 top-1/2 transform -translate-y-1/2 text-slate-400" />
  576. </div>
  577. </div>
  578. {/* 任务类型 */}
  579. <div>
  580. <Label className="text-sm font-bold">任务类型</Label>
  581. <div className="mt-2 space-y-2">
  582. {['换脸', '换衣', '换背景'].map((taskType) => (
  583. <div key={taskType} className="flex items-center space-x-2">
  584. <input
  585. type="checkbox"
  586. id={taskType}
  587. checked={selectedTaskTypes.includes(taskType)}
  588. onChange={() => handleTaskTypeToggle(taskType)}
  589. className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
  590. />
  591. <Label htmlFor={taskType} className="text-sm cursor-pointer">
  592. {taskType}
  593. </Label>
  594. </div>
  595. ))}
  596. </div>
  597. </div>
  598. {/* 每组生成数量 */}
  599. <div>
  600. <Label htmlFor="quantity" className="text-sm font-bold">每组生成数量</Label>
  601. <Input
  602. id="quantity"
  603. type="number"
  604. min="1"
  605. max="10"
  606. value={quantityPerGroup}
  607. onChange={(e) => setQuantityPerGroup(parseInt(e.target.value) || 1)}
  608. className="mt-2 w-full"
  609. />
  610. </div>
  611. {/* 场景模板 */}
  612. <div>
  613. <div className="flex items-center justify-between mb-2">
  614. <Label className="text-sm font-bold">场景模板</Label>
  615. <Badge variant="secondary" className="text-xs">
  616. {selectedSceneTemplates.length}
  617. </Badge>
  618. </div>
  619. <ScrollArea className="h-32">
  620. <div className="space-y-2">
  621. {sceneTemplates.map((template) => (
  622. <TemplateItem
  623. key={template.id}
  624. template={template}
  625. isSelected={selectedSceneTemplates.includes(template.id)}
  626. onSelect={(id) => handleSelection(setSelectedSceneTemplates, selectedSceneTemplates, id)}
  627. type="scene"
  628. />
  629. ))}
  630. </div>
  631. </ScrollArea>
  632. </div>
  633. {/* 文案模板 */}
  634. <div>
  635. <div className="flex items-center justify-between mb-2">
  636. <Label className="text-sm font-bold">文案模板</Label>
  637. <Badge variant="secondary" className="text-xs">
  638. {selectedCopyTemplates.length}
  639. </Badge>
  640. </div>
  641. <ScrollArea className="h-32">
  642. <div className="space-y-2">
  643. {copyTemplates.map((template) => (
  644. <TemplateItem
  645. key={template.id}
  646. template={template}
  647. isSelected={selectedCopyTemplates.includes(template.id)}
  648. onSelect={(id) => handleSelection(setSelectedCopyTemplates, selectedCopyTemplates, id)}
  649. type="copy"
  650. />
  651. ))}
  652. </div>
  653. </ScrollArea>
  654. </div>
  655. </CardContent>
  656. </Card>
  657. </div>
  658. {/* 生成进度 */}
  659. {isGenerating && (
  660. <Card className="mt-6">
  661. <CardHeader>
  662. <CardTitle className="flex items-center justify-between">
  663. <div className="flex items-center gap-2">
  664. <Loader2 className="w-5 h-5 animate-spin" />
  665. 生成进度
  666. </div>
  667. {currentTaskIds && currentTaskIds.length > 0 && (
  668. <Button
  669. variant="outline"
  670. size="sm"
  671. onClick={handleCancelTask}
  672. className="text-red-600 hover:text-red-700 hover:bg-red-50"
  673. >
  674. 取消任务
  675. </Button>
  676. )}
  677. </CardTitle>
  678. </CardHeader>
  679. <CardContent>
  680. <div className="space-y-4">
  681. <div className="flex justify-between items-center">
  682. <span className="text-sm font-medium">{currentTask}</span>
  683. <span className="text-sm text-slate-500">{generationProgress}%</span>
  684. </div>
  685. <Progress value={generationProgress} className="w-full" />
  686. {currentTaskIds && currentTaskIds.length > 0 && (
  687. <div className="text-xs text-slate-500 space-y-1">
  688. <div>任务数: {currentTaskIds.length}</div>
  689. <div>示例任务ID: {currentTaskIds[0]}</div>
  690. {taskStatus && (
  691. <div className="flex flex-wrap gap-3">
  692. <span>完成: {taskStatus.completed}</span>
  693. <span>进行中: {taskStatus.processing}</span>
  694. <span>等待: {taskStatus.pending}</span>
  695. <span>失败: {taskStatus.failed}</span>
  696. </div>
  697. )}
  698. </div>
  699. )}
  700. </div>
  701. </CardContent>
  702. </Card>
  703. )}
  704. </div>
  705. </div>
  706. );
  707. }