Files
video-gen/video-gen-app/src/pages/RemoveRw.tsx
T

2268 lines
144 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip, Select } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { getShotReplicationList, removeDetail, removeone, removetwo, removethree, removefour, getEngine, updateShotImagePrompt, updateShotVideoPromptSchema, calculateCredits, retryShotVideoPromptV2, updateShotVideoPromptSchemaV2, generateShotVideoV2 } from '../api/index';
import { useAuthStore } from '../store/useAuthStore';
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
import './css/InitialInfo.css';
const { Title, Text } = Typography;
const { TextArea } = Input;
function clonePlain<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
const buildMediaUrl = (url: string): string => {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
function InitialInfo() {
const navigate = useNavigate();
const { creatID } = useParams<{ creatID: string }>();
const [searchParams] = useSearchParams();
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
const { user, optimizeHoldCredits } = useAuthStore();
const [modalVisible, setModalVisible] = useState(false);
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
const [estimatedCredits, setEstimatedCredits] = useState<number>(0);
const [promptText, setPromptText] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentType, setCurrentType] = useState<string>('image');
const [formData, setFormData] = useState<any>({});
const [videoSchemaConfigSnapshot, setVideoSchemaConfigSnapshot] = useState<any>(null);
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingPromptStepId, setEditingPromptStepId] = useState('');
const [promptSaving, setPromptSaving] = useState(false);
const [retryPromptModalVisible, setRetryPromptModalVisible] = useState(false);
const [retryPromptStepId, setRetryPromptStepId] = useState<string>('');
const [retryPromptSubmitting, setRetryPromptSubmitting] = useState(false);
// 引擎和视频参数相关状态
const [enginesele, setEnginesele] = useState<any>({});
const [countType, setCountType] = useState('');
// 图片引擎 id:取自 getEngine().engine.image[0].id
const [imageEngineId, setImageEngineId] = useState<string>('');
const [showEngineModal, setShowEngineModal] = useState(false);
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
const [videoDuration, setVideoDuration] = useState(5);
const [videoAspectRatio, setVideoAspectRatio] = useState<string>('16:9');
const [videoResolution, setVideoResolution] = useState<string>('480p');
const [engineOptions, setEngineOptions] = useState<{
ratios: string[];
resolutions: string[];
durations: number[];
}>({
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: ['480p', '720p', '1080p'],
durations: [5, 8, 10, 12, 15],
});
// 创作记录表格数据
const [tableData, setTableData] = useState<any[]>([]);
// 分页状态
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
// 任务详情数据
const [taskDetail, setTaskDetail] = useState<any>({});
// API 返回的步骤数据
const [apiSteps, setApiSteps] = useState<any[]>([]);
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
// 预览相关状态
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = React.useRef<HTMLVideoElement>(null);
useEffect(() => {
if (previewVisible && previewType === 'video') {
const playVideo = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => { });
}
};
if (videoRef.current) {
if (videoRef.current.readyState >= 2) {
playVideo();
} else {
videoRef.current.addEventListener('loadedmetadata', playVideo);
}
}
const timer = setTimeout(playVideo, 300);
return () => {
clearTimeout(timer);
if (videoRef.current) {
videoRef.current.removeEventListener('loadedmetadata', playVideo);
videoRef.current.pause();
}
};
} else {
if (videoRef.current) {
videoRef.current.pause();
}
}
}, [previewVisible, previewType]);
const isV2 = flowVersion === 'v2';
const baseSteps = isV2
? [
{ id: 1, title: '素材与项目信息', description: '固定片段素材和项目描述', childId: 1 },
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
{ id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 },
]
: [
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
];
// 合并基础步骤和API返回的状态
const steps = baseSteps.map((step, index) => ({
...step,
status: apiSteps[index]?.status || '',
id: apiSteps[index]?.id || index,
output: apiSteps[index]?.output || '',
input: apiSteps[index]?.input || {},
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
}));
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
useEffect(() => {
const completedFailedKeys = new Set<string>();
activeKey.forEach(key => {
const step = steps.find(s => String(s.childId) === key);
if (step && (step.status === 'completed' || step.status === 'failed')) {
completedFailedKeys.add(key);
}
});
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
completedFailedKeys.add(String(steps[i].childId));
break;
}
}
if (completedFailedKeys.size > 0) {
setActiveKey(Array.from(completedFailedKeys));
} else {
setActiveKey([]);
}
}, [apiSteps]);
// 获取列表数据的函数
const fetchList = (page: number, size: number, keyword?: string) => {
getShotReplicationList(page, size, keyword).then((res: any) => {
if (res.items) {
setTableData(res.items);
}
if (res.total !== undefined) {
setTotal(res.total);
}
}).catch((error: any) => {
});
};
// 分页变化处理
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
fetchList(page, size, searchKeyword);
};
// 搜索处理
const handleSearch = () => {
fetchList(1, 20, searchKeyword);
};
// 获取复刻列表数据
useEffect(() => {
fetchList(currentPage, pageSize, searchKeyword);
}, []);
// 获取引擎列表
useEffect(() => {
getEngine()
.then((data: any) => {
const filteredVideoEngines = (data.engine?.video || []).filter((engine: any) => engine.supportsUniversalReference !== false);
const filteredImageEngines = (data.engine?.image || []).filter((engine: any) => engine.supportsUniversalReference !== false);
setEnginesele({
video: filteredVideoEngines,
image: filteredImageEngines,
});
// 默认选中第一个视频引擎
if (filteredVideoEngines.length > 0) {
setCountType(filteredVideoEngines[0].id);
// 设置默认引擎参数
const firstEngine = filteredVideoEngines[0];
setEngineOptions({
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
durations: firstEngine.supportedDurations || [5, 8, 10, 12, 15],
});
}
// 默认选中第一个图片引擎
if (filteredImageEngines.length > 0) {
setImageEngineId(filteredImageEngines[0].id);
}
})
.catch((error: any) => {
});
}, []);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
};
}, []);
// 点击外部关闭弹窗
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.image-settings-trigger') && !target.closest('.image-settings-popover')) {
setShowEngineModal(false);
setShowVideoSettingsModal(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// 获取任务详情数据
useEffect(() => {
if (creatID) {
removeDetail(creatID, flowVersion).then((res: any) => {
setTaskDetail(res);
if (res.steps) {
setApiSteps(res.steps);
}
}).catch((error: any) => {
message.error(error?.message || '加载任务详情失败');
});
}
}, [creatID, flowVersion]);
// 仅在项目或任一步骤处于 processing 时轮询;waiting_user 必须停止。
useEffect(() => {
const shouldPoll = taskDetail?.status === 'processing'
|| apiSteps.some((step: any) => step?.status === 'processing');
if (shouldPoll && !pollingTimerRef.current) {
pollingTimerRef.current = setInterval(pollTaskDetail, 30000);
} else if (!shouldPoll && pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
}, [taskDetail?.status, apiSteps, creatID, flowVersion]);
useEffect(() => {
calculateCredits().then((data: any) => {
setCreditCalculationData(data);
}).catch(() => { });
}, []);
const calculateEstimatedCredits = () => {
let config: any = {};
config = creditCalculationData.find((item: any) =>
item.modelConfigId === countType &&
item.genType === 'video' &&
item.resolution === videoResolution
);
if (!config) {
config = {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1.3,
inputVideoRatio: 1.3,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 15,
inputImageRatio: 1,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.0,
};
}
let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
const inputVideoDuration = taskDetail?.videoGeneration?.inputMedia?.video?.duration || 0;
if (inputVideoDuration > 0) {
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
total += inputVideoCost;
}
const inputImageCount = isV2
? (taskDetail?.material?.materialImageUrl ? 1 : 0)
: (taskDetail?.videoGeneration?.inputMedia?.image?.length || 0);
if (inputImageCount > 0) {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
setEstimatedCredits(Number(total.toFixed(2)));
};
const calculateCreditsFromVideoConfig = () => {
const videoConfig = steps[1]?.input?.payload?.videoConfig || steps[1]?.input?.payload?.video_config;
if (!videoConfig) {
return 0;
}
const engineId = videoConfig?.engineId || videoConfig?.engine_id;
const resolution = videoConfig?.resolution;
const duration = videoConfig?.duration || videoDuration;
let config: any = {};
config = creditCalculationData.find((item: any) =>
item.modelConfigId === engineId &&
item.genType === 'video' &&
item.resolution === resolution
);
if (!config) {
config = {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1.3,
inputVideoRatio: 1.3,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 15,
inputImageRatio: 1,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.0,
};
}
let total = (duration * config.perSecondCredits + config.baseCredits) * config.ratio;
const inputVideoDuration = taskDetail?.videoGeneration?.inputMedia?.video?.duration || 0;
if (inputVideoDuration > 0) {
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
total += inputVideoCost;
}
const inputImageCount = isV2
? (taskDetail?.material?.materialImageUrl ? 1 : 0)
: (taskDetail?.videoGeneration?.inputMedia?.image?.length || 0);
if (inputImageCount > 0) {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
};
useEffect(() => {
if (creditCalculationData.length > 0 && countType) {
calculateEstimatedCredits();
}
}, [creditCalculationData, countType, videoDuration, videoResolution, taskDetail]);
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number, schemaConfigSnapshot?: any) => {
setCurrentType(type || 'image');
setEditingPromptStepId(stepId ? String(stepId) : '');
if (type === 'video') {
setVideoSchemaConfigSnapshot(schemaConfigSnapshot || null);
setFormData(prompt && typeof prompt === 'object' ? clonePlain(prompt) : {});
setPromptText('');
} else {
setVideoSchemaConfigSnapshot(null);
setPromptText(prompt || '');
setFormData({});
}
setModalVisible(true);
};
const handleConfirm = async () => {
if (currentType === 'image') {
if (!taskDetail?.id || !editingPromptStepId) {
message.warning('缺少任务或步骤 ID,无法保存图片提示词');
return;
}
if (!promptText || !promptText.trim()) {
message.warning('图片提示词不能为空');
return;
}
setPromptSaving(true);
try {
await updateShotImagePrompt(taskDetail.id, editingPromptStepId, {
prompt: promptText.trim(),
});
message.success('图片提示词已保存');
refreshTaskDetail();
setModalVisible(false);
setEditingPromptStepId('');
} catch (error: any) {
message.error(error?.message || '保存图片提示词失败');
} finally {
setPromptSaving(false);
}
return;
}
if (!taskDetail?.id || !editingPromptStepId) {
message.warning('缺少任务或步骤 ID,无法保存视频提示词');
return;
}
if (!formData || typeof formData !== 'object' || Object.keys(formData).length === 0) {
message.warning('视频提示词不能为空');
return;
}
if (!videoSchemaConfigSnapshot) {
message.warning('视频提词配置缺失,暂不能保存');
return;
}
const schemaError = validateVideoPromptSchemaByConfig(formData, videoSchemaConfigSnapshot);
if (schemaError) {
message.warning(schemaError);
return;
}
setPromptSaving(true);
try {
const updateVideoPrompt = isV2 ? updateShotVideoPromptSchemaV2 : updateShotVideoPromptSchema;
await updateVideoPrompt(taskDetail.id, editingPromptStepId, {
prompt_schema: formData,
});
message.success('视频提示词已保存');
refreshTaskDetail();
setModalVisible(false);
setEditingPromptStepId('');
} catch (error: any) {
message.error(error?.message || '保存视频提示词失败');
} finally {
setPromptSaving(false);
}
};
// 轮询任务详情
const pollTaskDetail = () => {
if (!creatID) {
return;
}
removeDetail(creatID, flowVersion).then((res: any) => {
setTaskDetail(res);
if (res.steps) {
setApiSteps(res.steps);
}
}).catch((error: any) => {
console.error('轮询任务详情失败', error);
});
};
// 刷新任务详情(用于点击下一步后更新数据)
const refreshTaskDetail = () => {
if (!creatID) return;
removeDetail(creatID, flowVersion).then((res: any) => {
setTaskDetail(res);
if (res.steps) {
setApiSteps(res.steps);
}
}).catch((error: any) => {
message.error(error?.message || '刷新任务详情失败');
});
};
const openPreview = (url: string, type: 'image' | 'video') => {
setPreviewUrl(url);
setPreviewType(type);
setPreviewVisible(true);
};
const handleClosePreview = () => {
setPreviewVisible(false);
setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
};
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!previewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const createone = (stepId: number) => {
removeone(taskDetail.id, stepId.toString()).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成图片提示词,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
const createimage = (stepId: number) => {
let params = {
engine_id: imageEngineId,
image_proportion: "1:1",
image_px: "2048x2048",
image_size: "2K"
}
removetwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成图片,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
const newcreateimage = () => {
// console.log('下一步:', stepId);
let params = {
engine_id: imageEngineId,
image_proportion: "1:1",
image_px: "2048x2048",
image_size: "2K"
}
removetwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
message.info('正在生成图片,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
// 下一步按钮点击处理
const handleNextStep = (stepId: number) => {
// 获取引擎 ID 和视频参数
const engineId = countType;
const videoParams = {
engine_id: countType,
duration: videoDuration,
aspect_ratio: videoAspectRatio,
resolution: videoResolution,
target_platform: "抖音",
};
// console.log('引擎 ID:', engineId);
removethree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成视频提示词,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
// 这里可以添加下一步的逻辑,比如调用接口等
};
const createvideo = async (stepId: number, engineId: string) => {
try {
if (isV2) {
await generateShotVideoV2(taskDetail.id, String(stepId));
} else {
await removefour(taskDetail.id, String(stepId), { engine_id: engineId || '' });
}
message.info('正在生成视频,请稍候...');
refreshTaskDetail();
} catch (error: any) {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
}
};
const agincreatevideo = async () => {
const promptStep = isV2 ? steps[1] : steps[3];
if (!promptStep?.id) return;
await createvideo(promptStep.id, promptStep.engineId || '');
};
const applyRetryVideoEngine = (
engineId: string,
preferred?: { duration?: number; aspectRatio?: string; resolution?: string },
) => {
const engine = (enginesele.video || []).find((item: any) => String(item.id) === String(engineId));
if (!engine) {
return false;
}
const ratios = Array.isArray(engine.supportedRatios) && engine.supportedRatios.length > 0
? engine.supportedRatios.map((item: any) => String(item))
: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
const resolutions = Array.isArray(engine.supportedResolutions) && engine.supportedResolutions.length > 0
? engine.supportedResolutions.map((item: any) => String(item))
: ['480p', '720p', '1080p'];
const parsedDurations = Array.isArray(engine.supportedDurations)
? engine.supportedDurations
.map((item: any) => Number(item))
.filter((item: number) => Number.isFinite(item) && item > 0)
: [];
const durations = parsedDurations.length > 0 ? parsedDurations : [5, 8, 10, 12, 15];
const preferredDuration = Number(preferred?.duration ?? videoDuration);
const preferredRatio = String(preferred?.aspectRatio || videoAspectRatio || '');
const preferredResolution = String(preferred?.resolution || videoResolution || '');
setCountType(String(engine.id));
setEngineOptions({ ratios, resolutions, durations });
setVideoDuration(durations.includes(preferredDuration) ? preferredDuration : durations[0]);
setVideoAspectRatio(ratios.includes(preferredRatio) ? preferredRatio : ratios[0]);
setVideoResolution(resolutions.includes(preferredResolution) ? preferredResolution : resolutions[0]);
return true;
};
const openRegenerateVideoPrompt = (stepId: number) => {
if (!isV2 || !taskDetail?.id) return;
const currentConfig = taskDetail?.videoGeneration?.promptParams || taskDetail?.videoGeneration?.params || {};
const requestedEngineId = String(
currentConfig.engineId
|| currentConfig.engine_id
|| taskDetail?.videoGeneration?.engineId
|| countType
|| '',
);
const currentEngineId = String(
(enginesele.video || []).some((engine: any) => String(engine.id) === requestedEngineId)
? requestedEngineId
: enginesele.video?.[0]?.id || '',
);
if (!currentEngineId || !applyRetryVideoEngine(currentEngineId, {
duration: Number(currentConfig.duration || videoDuration),
aspectRatio: String(currentConfig.aspectRatio || currentConfig.aspect_ratio || videoAspectRatio),
resolution: String(currentConfig.resolution || videoResolution),
})) {
message.warning('当前没有可用的视频生成引擎');
return;
}
setRetryPromptStepId(String(stepId));
setRetryPromptModalVisible(true);
};
const submitRegenerateVideoPrompt = async (stepId: string) => {
if (!isV2 || !taskDetail?.id || !stepId || !countType) return;
setRetryPromptSubmitting(true);
try {
await retryShotVideoPromptV2(taskDetail.id, stepId, {
video_config: {
engine_id: countType,
duration: videoDuration,
aspect_ratio: videoAspectRatio,
resolution: videoResolution,
},
});
message.info('正在按新视频参数重新生成视频提示词,请稍候...');
refreshTaskDetail();
} catch (error: any) {
message.error(error?.message || '重新生成视频提示词失败');
} finally {
setRetryPromptSubmitting(false);
}
};
return (
<React.Fragment>
<div
className="initialinfo-container"
style={{
margin: '-24px -32px -32px',
height: 'calc(100vh - 34px)',
position: 'relative',
boxSizing: 'border-box',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* 背景装饰 */}
<div style={{ position: 'absolute', top: -100, right: -100, width: 300, height: 300, background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)', borderRadius: '50%', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -100, left: -100, width: 300, height: 300, background: 'radial-gradient(circle, rgba(167,139,250,0.08) 0%, transparent 70%)', borderRadius: '50%', pointerEvents: 'none' }} />
<div style={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'stretch', gap: '2%', position: 'relative', zIndex: 1, minHeight: 0 }}>
<div
className="replication-preview"
style={{
flex: 4,
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
overflow: 'hidden',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<div style={{ borderBottom: '1px solid rgba(99, 102, 241, 0.08)', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 24px', background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', boxSizing: 'border-box' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => window.history.back()}
style={{
borderRadius: 10,
fontSize: 13,
height: 32,
background: 'rgba(99, 102, 241, 0.1)',
border: '1px solid rgba(99, 102, 241, 0.2)',
color: '#6366f1',
}}
>
返回
</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
拆镜复刻 - 任务详情
</h3>
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)', borderRadius: 1 }} />
</div>
<Button
type="primary"
ghost
style={{
borderRadius: 10,
padding: '4px 16px',
fontSize: 13,
background: 'rgba(99, 102, 241, 0.1)',
borderColor: 'rgba(99, 102, 241, 0.2)',
color: '#6366f1',
}}
onClick={() => setIsModalOpen(true)}
>
创作记录
</Button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
<div className="material_box">
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 250 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
视频
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
{taskDetail?.material?.materialVideoUrl ? (
<video
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>暂无视频</div>
)}
</div>
</div>
<div className="product_img" style={{ flex: 1, minWidth: 250 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
产品图片
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
{taskDetail?.material?.materialImageUrl ? (
<img src={buildMediaUrl(taskDetail.material.materialImageUrl)} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>暂无图片</div>
)}
</div>
</div>
</div>
{taskDetail?.finalImageUrl && (
<div style={{ marginTop: 24 }}>
<div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
生成图片
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalImageUrl, 'image')}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
</div>
</div>
)}
{taskDetail?.finalVideoUrl && (
<div style={{ marginTop: 24 }}>
<div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
生成视频
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div
className="replication-form"
style={{
flex: 1,
minWidth: '450px',
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
overflow: 'hidden',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', display: 'flex', alignItems: 'center', gap: 10, background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)' }}>
<div style={{ width: 4, height: 18, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 15, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>生成步骤</span>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 16px' }}>
<Collapse
activeKey={activeKey}
onChange={setActiveKey}
ghost
bordered={false}
style={{ background: 'transparent' }}
expandIconPlacement="end"
items={steps.map((step) => ({
key: String(step.childId),
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16 }}>
{step.status === 'completed' ? (
<CheckCircleOutlined style={{ fontSize: 16, color: '#22c55e' }} />
) : step.status === 'processing' ? (
<div style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid #e2e8f0', borderTopColor: '#6366f1', animation: 'spinSlow 1s linear infinite' }} />
) : step.status === 'failed' ? (
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#ef4444', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#fff', fontSize: 10 }}>×</span>
</div>
) : (
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#cbd5e1' }} />
)}
</div>
<span style={{ fontSize: 14, color: step.status === 'processing' || step.status === 'waiting_user' ? '#6366f1' : '#1e293b', fontWeight: 500 }}>
{step.title}
</span>
{(step.status === 'processing' || step.status === 'waiting_user') && (
<span style={{ fontSize: 12, color: '#6366f1', marginLeft: 8 }}>生成中</span>
)}
{step.status === 'failed' && (
<span style={{ fontSize: 12, color: '#ef4444', marginLeft: 8 }}>失败</span>
)}
{step.status === '' && (
<span style={{ fontSize: 12, color: '#717d8b', marginLeft: 8 }}>待生成</span>
)}
</div>
</div>
),
children: (
<div style={{ padding: '16px', background: 'linear-gradient(135deg, rgba(248,250,252,0.6) 0%, rgba(238,242,255,0.4) 100%)', borderRadius: 12, marginTop: 8, border: '1px solid rgba(99, 102, 241, 0.06)' }}>
{/* 步骤1: 原始素材 */}
{step.childId === 1 && (
<>
<div style={{ display: 'flex', gap: 20, marginBottom: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<Text style={{ color: '#64748b', fontSize: 13, marginBottom: 10, display: 'block', fontWeight: 500 }}>原视频</Text>
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.06)' }}>
{taskDetail?.material?.materialVideoUrl ? (
<video
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
controls
/>
) : (
<div style={{ textAlign: 'center', color: '#94a3b8' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginBottom: 8 }}>
<rect x="2" y="2" width="20" height="16" rx="2" ry="2" />
<path d="M8 12l4 2 4-2" />
</svg>
<div style={{ fontSize: 13 }}>暂无视频</div>
</div>
)}
</div>
</div>
<div style={{ flex: 1, minWidth: 200 }}>
<Text style={{ color: '#64748b', fontSize: 13, marginBottom: 10, display: 'block', fontWeight: 500 }}>产品图片</Text>
<div style={{ height: 180, borderRadius: 12, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(99, 102, 241, 0.06)' }}>
{taskDetail?.material?.materialImageUrl ? (
<img
src={buildMediaUrl(taskDetail.material.materialImageUrl)}
alt="产品图片"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10 }}
/>
) : (
<div style={{ textAlign: 'center', color: '#94a3b8' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginBottom: 8 }}>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
<path d="M21 15l-5-5L5 21" />
</svg>
<div style={{ fontSize: 13 }}>暂无图片</div>
</div>
)}
</div>
</div>
</div>
<div>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 8 }}>原产品名称:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{steps[0]?.input?.payload?.sourceProjectName || '-'}</span>
</div>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 8 }}>自有产品名称:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{steps[0]?.input?.payload?.targetProjectName || '-'}</span>
</div>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 8 }}>产品卖点:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{steps[0]?.input?.payload?.coreContentPoint || '-'}</span>
</div>
</div>
{!isV2 && (
<Tooltip
title={((user?.credits || 0) < optimizeHoldCredits) ? `积分不足${optimizeHoldCredits},请充值积分` : ''}
placement="top"
>
<Button
onClick={() => {
if ((user?.credits || 0) < optimizeHoldCredits) {
message.warning(`积分不足${optimizeHoldCredits},请充值积分`);
return;
}
createone(step.id);
}}
type="primary"
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={step.status !== 'completed' || ((user?.credits || 0) < optimizeHoldCredits)}
>
下一步:生成图片提示词
</Button>
</Tooltip>
)}
</>
)}
{/* 步骤2: 生成提示词 */}
{step.childId === 2 && (
<>
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
<Text style={{ color: '#475569', fontSize: 13, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
{step?.output?.payload?.prompt || '暂无提示词'}
</Text>
</div>
<Space style={{ marginTop: 16, gap: 12, width: '100%' }}>
<Button
type="default"
icon={<EditOutlined />}
onClick={() => handleOpenModal(step?.output?.payload?.prompt, 'image', step.id)}
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
disabled={step.status !== 'completed'}
>
修改提示词
</Button>
<Button onClick={() => { createimage(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
下一步:生成图片
</Button>
</Space>
</>
)}
{/* 步骤3: 生成产品融合图 */}
{step.childId === 3 && (
<>
<div style={{ borderRadius: 12, overflow: 'hidden', marginBottom: 16, background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', border: '1px solid rgba(99, 102, 241, 0.08)', boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)', position: 'relative' }}>
{taskDetail.finalImageUrl ? (
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`} alt="融合图" style={{ width: '100%', height: 180, objectFit: 'cover' }} />
) : (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}>暂无融合图</div>
)}
<Button
type="default"
icon={<EditOutlined />}
onClick={() => newcreateimage()}
disabled={step.status !== 'completed'}
style={{
position: 'absolute',
bottom: 8,
left: 8,
borderRadius: 8,
borderColor: 'rgba(99, 102, 241, 0.3)',
color: '#6366f1',
height: 30,
fontWeight: 500,
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(4px)',
padding: '0 12px',
fontSize: 12,
}}
>
重新生成
</Button>
</div>
{/* 引擎选择器和视频参数设置 */}
<p style={{ marginBottom: 6, fontSize: 14, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>视频参数选择:</p>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ flex: 1, position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowEngineModal(!showEngineModal);
setShowVideoSettingsModal(false);
}}
className="image-settings-trigger"
style={{
width: '100%',
padding: '4px 12px',
height: 34,
borderRadius: 10,
border: '1px solid rgba(99, 102, 241, 0.15)',
backgroundColor: 'rgba(255,255,255,0.7)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
}}
>
<SettingOutlined style={{ fontSize: 14, color: '#6366f1' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#64748b',
}}>
{enginesele.video?.find((e: any) => e.id === countType)?.name || '选择引擎'}
</Text>
</button>
{showEngineModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: -10,
width: 350,
backgroundColor: '#fff',
borderRadius: 16,
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
padding: 16,
border: 'none',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择引擎 */}
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择引擎
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
{enginesele.video?.map((engine: any) => (
<button
key={engine.id}
onClick={() => {
setCountType(engine.id);
// 根据选中的引擎更新视频参数选项
setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
});
// 更新默认选中值,确保在新引擎支持的范围内
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
}
if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p');
}
if (!engine.supportedDurations?.includes(videoDuration)) {
setVideoDuration(engine.supportedDurations?.[0] || 5);
}
setShowEngineModal(false);
}}
style={{
flex: 1,
minHeight: 48,
borderRadius: 8,
border: countType === engine.id
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: countType === engine.id
? '#fff'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
}}
>
<span style={{
fontSize: 13,
fontWeight: countType === engine.id ? 600 : 500,
color: countType === engine.id ? '#6366f1' : '#4b5563',
marginBottom: 2,
}}>
{engine.name}
</span>
<span style={{
fontSize: 11,
color: '#9ca3af',
}}>
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 视频参数设置 */}
<div style={{ flex: 1, position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowVideoSettingsModal(!showVideoSettingsModal);
setShowEngineModal(false);
}}
className="image-settings-trigger"
style={{
width: '100%',
padding: '4px 12px',
height: 34,
borderRadius: 10,
border: '1px solid rgba(99, 102, 241, 0.15)',
backgroundColor: 'rgba(255,255,255,0.7)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
}}
>
<LayoutOutlined style={{ fontSize: 14, color: '#6366f1' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#64748b',
}}>
{videoAspectRatio} · {videoDuration}s · {videoResolution}
</Text>
</button>
{showVideoSettingsModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: -160,
width: 350,
backgroundColor: '#fff',
borderRadius: 16,
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
padding: 16,
border: 'none',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择比例 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择比例
</Text>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
}}>
{engineOptions.ratios.map((ratio) => (
<button
key={ratio}
onClick={() => setVideoAspectRatio(ratio)}
style={{
flex: '0 0 calc(14.28% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
border: videoAspectRatio === ratio
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: videoAspectRatio === ratio
? '#fff'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<div style={{
width: 18,
height: 18,
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
marginBottom: 2,
}} />
<span style={{
fontSize: 9,
color: videoAspectRatio === ratio
? '#6366f1'
: '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
))}
</div>
</div>
{/* 选择时长 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择时长
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, position: 'relative', height: 24, display: 'flex', alignItems: 'center' }}>
{/* 背景轨道 */}
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: 6,
borderRadius: 3,
background: '#e5e7eb',
transform: 'translateY(-50%)',
}} />
{/* 已滑动部分 */}
<div style={{
position: 'absolute',
top: '50%',
left: 0,
height: 6,
borderRadius: 3,
background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)',
}} />
{/* 滑块 */}
<input
type="range"
min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)}
value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))}
style={{
position: 'relative',
width: '100%',
height: 24,
borderRadius: 3,
background: 'transparent',
outline: 'none',
appearance: 'none',
cursor: 'pointer',
zIndex: 1,
}}
/>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 12px',
backgroundColor: '#f1f5f9',
borderRadius: 6,
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#64748b' }}>
{videoDuration}
</span>
<span style={{ fontSize: 12, color: '#9ca3af' }}></span>
</div>
</div>
</div>
{/* 选择分辨率 */}
<div>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择分辨率
</Text>
<div style={{ display: 'flex', gap: 6 }}>
{engineOptions.resolutions.map((resolution) => (
<button
key={resolution}
onClick={() => setVideoResolution(resolution)}
style={{
flex: 1,
height: 42,
borderRadius: 6,
border: videoResolution === resolution
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: videoResolution === resolution
? '#6366f1'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<span style={{
fontSize: 12,
fontWeight: 600,
color: videoResolution === resolution
? '#fff'
: '#4b5563',
}}>
{resolution}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
</div>
<Tooltip
title={((user?.credits || 0) < estimatedCredits) ? '积分不足,请更换参数/充值积分' : ''}
placement="top"
>
<Button
type="primary"
style={{
width: '100%',
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
height: 36,
fontWeight: 500,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)'
}}
onClick={() => {
if ((user?.credits || 0) < estimatedCredits) {
message.warning('积分不足,请更换参数/充值积分');
return;
}
handleNextStep(step.id);
}}
disabled={step.status !== 'completed' || ((user?.credits || 0) < estimatedCredits)}
>
下一步:生成视频提示词
<span style={{ color: '#fff', marginLeft: 8 }}>
视频生成预估积分:{estimatedCredits}
</span>
</Button>
</Tooltip>
</>
)}
{/* 步骤4: 生成视频提示词 */}
{step.childId === 4 && (
<>
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
<Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.8 }}>
视频提词已生成,可点击按钮查看/修改
</Text>
</div>
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
{(() => {
const videoConfig = steps[1]?.input?.payload?.videoConfig || steps[1]?.input?.payload?.video_config;
const engineId = videoConfig?.engineId || videoConfig?.engine_id;
const engineName = engineId ? (enginesele.video || []).find((e: any) => String(e.id) === String(engineId))?.name : '';
return (
<>
<div>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 4 }}>引擎:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{engineName || '-'}</span>
</div>
<div>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 4 }}>比例:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{videoConfig?.aspectRatio || videoConfig?.aspect_ratio || '-'}</span>
</div>
<div>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 4 }}>分辨率:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{videoConfig?.resolution || '-'}</span>
</div>
<div>
<span style={{ fontSize: 12, fontWeight: 500, color: '#666666', marginRight: 4 }}>时长:</span>
<span style={{ fontSize: 13, color: '#4b5563' }}>{videoConfig?.duration || '-'}</span>
</div>
</>
);
})()}
</div>
</div>
<Space style={{ marginTop: 16, gap: 12, width: '100%', flexWrap: 'wrap' }}>
<Button
type="default"
icon={<EditOutlined />}
onClick={() => handleOpenModal(taskDetail?.videoGeneration?.promptSchema, 'video', step.id, taskDetail?.videoGeneration?.schemaConfigSnapshot)}
style={{ flex: '0 0 auto', minWidth: 160, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
disabled={step.status !== 'completed' || !taskDetail?.videoGeneration?.promptSchema || !taskDetail?.videoGeneration?.schemaConfigSnapshot}
>
查看/修改视频提词
</Button>
{isV2 && (
<Tooltip
title={((user?.credits || 0) < optimizeHoldCredits) ? `积分不足${optimizeHoldCredits},请充值积分` : ''}
placement="top"
>
<Button
type="default"
onClick={() => {
if ((user?.credits || 0) < optimizeHoldCredits) {
message.warning(`积分不足${optimizeHoldCredits},请充值积分`);
return;
}
setRetryPromptStepId(String(step.id));
setRetryPromptModalVisible(true);
}}
style={{ flex: '0 0 auto', minWidth: 160, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500 }}
disabled={step.status === 'processing' || steps[2]?.status === 'processing' || ((user?.credits || 0) < optimizeHoldCredits)}
>
重新生成视频提词
</Button>
</Tooltip>
)}
<Tooltip
title={((user?.credits || 0) < calculateCreditsFromVideoConfig()) ? `积分不足${calculateCreditsFromVideoConfig()},请充值积分` : ''}
placement="top"
>
<Button
onClick={() => {
const credits = calculateCreditsFromVideoConfig();
if ((user?.credits || 0) < credits) {
message.warning(`积分不足${credits},请充值积分`);
return;
}
createvideo(step.id, step.engineId);
}}
type="primary"
style={{ flex: '1 0 auto', minWidth: 140, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={step.status !== 'completed' || ((user?.credits || 0) < calculateCreditsFromVideoConfig())}
>
下一步:生成视频(所需积分:{calculateCreditsFromVideoConfig()})
</Button>
</Tooltip>
</Space>
</>
)}
{/* 步骤5: 生成最终视频 */}
{step.childId === 5 && (
<>
<div style={{ borderRadius: 12, overflow: 'hidden', marginBottom: 16, position: 'relative', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', border: '1px solid rgba(99, 102, 241, 0.08)', boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)' }}>
{step.status === 'completed' && taskDetail?.finalVideoUrl ? (
<video src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} style={{ width: '100%', height: 180, objectFit: 'cover' }} controls />
) : step.status === 'processing' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ width: 20, height: 20, borderRadius: '50%', border: '2px solid #e2e8f0', borderTopColor: '#6366f1', animation: 'spinSlow 1s linear infinite' }} />
<span style={{ color: '#6366f1', fontSize: 14 }}>视频生成中...</span>
</div>
) : step.status === 'failed' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 14 }}>视频生成失败</div>
) : (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}>暂无视频</div>
)}
</div>
<Space style={{ width: '100%', gap: 12 }}>
<Tooltip
title={((user?.credits || 0) < calculateCreditsFromVideoConfig()) ? `积分不足${calculateCreditsFromVideoConfig()},请充值积分` : ''}
placement="top"
>
<Button
onClick={() => {
const credits = calculateCreditsFromVideoConfig();
if ((user?.credits || 0) < credits) {
message.warning(`积分不足${credits},请充值积分`);
return;
}
agincreatevideo();
}}
type="default"
icon={<EditOutlined />}
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
disabled={isV2 ? !['completed', 'failed'].includes(step.status) : step.status !== 'completed' || ((user?.credits || 0) < calculateCreditsFromVideoConfig())}
>
{step.status === 'failed' ? '重试生成' : '重新生成'}(所需积分:{calculateCreditsFromVideoConfig()})
</Button>
</Tooltip>
<Button
type="primary"
icon={<DownloadOutlined />}
style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={step.status !== 'completed'}
onClick={() => {
const videoUrl = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail?.finalVideoUrl}&download=1`;
const link = document.createElement('a');
link.href = videoUrl;
link.download = `video_${Date.now()}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
下载视频
</Button>
</Space>
</>
)}
</div>
),
}))}
/>
</div>
</div>
</div>
</div>
<Modal
open={retryPromptModalVisible}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, color: '#6366f1', letterSpacing: 0.4 }}>
重新生成视频提词
</span>
</div>
}
onCancel={() => setRetryPromptModalVisible(false)}
width={400}
footer={null}
centered
style={{ borderRadius: 16 }}
styles={{
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
}}
>
<div style={{ padding: '16px 0' }}>
<div style={{ marginBottom: 16 }}>
<span style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择引擎
</span>
<Select
value={countType}
onChange={(value) => {
const engine = enginesele.video?.find((e: any) => e.id === value);
if (engine) {
setCountType(engine.id);
setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
});
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
}
if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p');
}
if (!engine.supportedDurations?.includes(videoDuration)) {
setVideoDuration(engine.supportedDurations?.[0] || 5);
}
}
}}
style={{ width: '100%', borderRadius: 8 }}
options={enginesele.video?.map((engine: any) => ({
value: engine.id,
label: engine.name,
}))}
placeholder="请选择引擎"
/>
</div>
<div style={{ marginBottom: 16 }}>
<span style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择比例
</span>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
}}>
{engineOptions.ratios.map((ratio) => (
<button
key={ratio}
onClick={() => setVideoAspectRatio(ratio)}
style={{
flex: '0 0 calc(14.28% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
border: videoAspectRatio === ratio
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: videoAspectRatio === ratio
? '#fff'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<div style={{
width: 18,
height: 18,
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
marginBottom: 2,
}} />
<span style={{
fontSize: 9,
color: videoAspectRatio === ratio
? '#6366f1'
: '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
))}
</div>
</div>
<div style={{ marginBottom: 16 }}>
<span style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择时长
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, position: 'relative', height: 24, display: 'flex', alignItems: 'center' }}>
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: 6,
borderRadius: 3,
background: '#e5e7eb',
transform: 'translateY(-50%)',
}} />
<div style={{
position: 'absolute',
top: '50%',
left: 0,
height: 6,
borderRadius: 3,
background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)',
}} />
<input
type="range"
min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)}
value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))}
style={{
position: 'relative',
width: '100%',
height: 24,
borderRadius: 3,
background: 'transparent',
outline: 'none',
appearance: 'none',
cursor: 'pointer',
zIndex: 1,
}}
/>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 12px',
backgroundColor: '#f1f5f9',
borderRadius: 6,
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#64748b' }}>
{videoDuration}
</span>
<span style={{ fontSize: 12, color: '#9ca3af' }}></span>
</div>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<span style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择分辨率
</span>
<div style={{ display: 'flex', gap: 6 }}>
{engineOptions.resolutions.map((resolution) => (
<button
key={resolution}
onClick={() => setVideoResolution(resolution)}
style={{
flex: 1,
height: 42,
borderRadius: 8,
border: videoResolution === resolution
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: videoResolution === resolution
? '#6366f1'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<span style={{
fontSize: 12,
fontWeight: 600,
color: videoResolution === resolution
? '#fff'
: '#4b5563',
}}>
{resolution}
</span>
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
<Button
onClick={() => setRetryPromptModalVisible(false)}
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500 }}
>
取消
</Button>
<Button
onClick={() => {
if (!countType) {
message.warning('请选择视频引擎');
return;
}
submitRegenerateVideoPrompt(retryPromptStepId);
setRetryPromptModalVisible(false);
}}
type="primary"
style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={!countType}
>
确认生成
</Button>
</div>
</div>
</Modal>
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 4, height: 18, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 15, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
{currentType === 'video' ? '查看/编辑视频提词' : '修改提示词'}
</span>
</div>
}
open={modalVisible}
onCancel={() => { if (!promptSaving) setModalVisible(false); }}
footer={[
<Button key="cancel" onClick={() => setModalVisible(false)} disabled={promptSaving} style={{ borderRadius: 8 }}>取消</Button>,
<Button key="confirm" type="primary" onClick={handleConfirm} loading={promptSaving} disabled={promptSaving} style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}>确认</Button>,
]}
width={800}
style={{ borderRadius: 16 }}
styles={{
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
body: { background: 'linear-gradient(135deg, #f8fafc 0%, #eef2ff 50%, #f0f9ff 100%)', padding: '20px 24px' },
}}
>
{currentType === 'image' ? (
<TextArea
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
rows={8}
style={{ width: '100%', height: 300, borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.15)', background: 'rgba(255,255,255,0.8)' }}
placeholder="请输入提示词"
/>
) : (
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
<VideoPromptSchemaEditor value={formData} schemaConfigSnapshot={videoSchemaConfigSnapshot} onChange={setFormData} />
</div>
)}
</Modal>
{/* 创作记录弹窗 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>创作记录</span>
</div>
}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={800}
footer={null}
styles={{
body: { background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)' },
header: { background: '#fff', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', padding: '20px 24px' },
}}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16, gap: 8 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{
width: 200,
borderRadius: 10,
border: '1px solid rgba(99, 102, 241, 0.2)',
height: 40,
}}
/>
<Button
type="primary"
onClick={handleSearch}
style={{
borderRadius: 10,
height: 40,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
}}
>
搜索
</Button>
</div>
<div
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
backdropFilter: 'blur(20px)',
borderRadius: 16,
overflow: 'hidden',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
}}
>
<Table
columns={[
{
title: '产品名称',
dataIndex: 'title',
key: 'title',
render: (text: string) => (
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => {
if (!text) return '-';
const date = new Date(text);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return <span style={{ fontSize: 13, color: '#64748b' }}>{`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`}</span>;
},
},
{
title: '操作',
key: 'action',
align: 'center',
render: (record) => (
<Button
type="text"
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{
color: '#6366f1',
fontSize: 13,
padding: '4px 12px',
borderRadius: 6,
background: 'rgba(99, 102, 241, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
}}
>
查看详情
</Button>
),
},
]}
dataSource={tableData}
rowKey="id"
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePageChange,
style: {
padding: '16px 24px',
borderTop: '1px solid rgba(99, 102, 241, 0.1)',
},
}}
style={{ fontSize: 13 }}
scroll={{ y: 350 }}
components={{
body: {
row: ({ className, style, ...rest }) => (
<tr
{...rest}
className={className}
style={{
...style,
transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
}}
/>
),
cell: ({ className, style, ...rest }) => (
<td
{...rest}
className={className}
style={{
...style,
padding: '16px 24px',
}}
/>
),
},
header: {
cell: ({ className, style, ...rest }) => (
<th
{...rest}
className={className}
style={{
...style,
background: 'rgba(99, 102, 241, 0.03)',
color: '#64748b',
fontWeight: 500,
fontSize: 13,
padding: '16px 24px',
borderBottom: 'none',
}}
/>
),
},
}}
/>
</div>
</Modal>
{/* 图片/视频预览弹窗 */}
<Modal
open={previewVisible}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #8b5cf6 0%, #ddd6fe 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, color: '#8b5cf6', letterSpacing: 0.4 }}>
预览
</span>
</div>
}
onCancel={handleClosePreview}
width={800}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(139, 92, 246, 0.08)' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
style={{ borderRadius: 10, background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)', border: 'none', boxShadow: '0 8px 18px rgba(47, 52, 64, 0.15)' }}
>
下载
</Button>
</div>
}
centered
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
},
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(139, 92, 246, 0.08)', padding: '16px 24px' },
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{previewType === 'image' ? (
<img
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={videoRef}
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
</React.Fragment>
);
}
const FormRenderer = ({ data, onChange }: { data: any; onChange: (data: any) => void }) => {
const handleFieldChange = (path: string[], value: any) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]];
}
current[path[path.length - 1]] = value;
onChange(newData);
};
const handleArrayItemChange = (path: string[], index: number, value: any) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length; i++) {
if (i === path.length - 1) {
current[path[i]] = [...current[path[i]]];
current[path[i]][index] = value;
} else {
current = current[path[i]];
}
}
onChange(newData);
};
const handleArrayAdd = (path: string[]) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length; i++) {
if (i === path.length - 1) {
current[path[i]] = [...current[path[i]], ''];
} else {
current = current[path[i]];
}
}
onChange(newData);
};
const handleArrayRemove = (path: string[], index: number) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length; i++) {
if (i === path.length - 1) {
current[path[i]] = current[path[i]].filter((_: any, i: number) => i !== index);
} else {
current = current[path[i]];
}
}
onChange(newData);
};
const renderField = (key: string, value: any, path: string[]) => {
if (Array.isArray(value)) {
return (
<div key={key} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ color: '#374151', fontSize: 13 }}>{key}</Text>
<Button
type="text"
size="small"
onClick={() => handleArrayAdd(path)}
style={{ marginLeft: 8, color: '#6366f1', fontSize: 12 }}
>
+ 添加
</Button>
</div>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 12, background: '#f9fafb' }}>
{value.map((item: any, index: number) => (
<div key={index} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<span style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>{index + 1}.</span>
<div style={{ flex: 1 }}>
{typeof item === 'object' ? (
<FormRenderer
data={item}
onChange={(newItem) => handleArrayItemChange(path, index, newItem)}
/>
) : (
<Input
value={item}
onChange={(e) => handleArrayItemChange(path, index, e.target.value)}
style={{ width: '100%', borderRadius: 6 }}
/>
)}
</div>
<Button
type="text"
danger
onClick={() => handleArrayRemove(path, index)}
style={{ marginTop: 4 }}
>
删除
</Button>
</div>
))}
</div>
</div>
);
}
if (typeof value === 'object' && value !== null) {
return (
<div key={key} style={{ marginBottom: 16 }}>
<Text strong style={{ color: '#374151', fontSize: 13, marginBottom: 8, display: 'block' }}>
{key}
</Text>
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}>
<FormRenderer data={value} onChange={(newValue) => handleFieldChange(path, newValue)} />
</div>
</div>
);
}
return (
<div key={key} style={{ marginBottom: 12 }}>
<Text style={{ color: '#6b7280', fontSize: 12, marginBottom: 4, display: 'block' }}>{key}</Text>
<Input
value={value}
onChange={(e) => handleFieldChange(path, e.target.value)}
style={{ width: '100%', borderRadius: 6 }}
/>
</div>
);
};
return (
<div>
{Object.entries(data).map(([key, value]) => renderField(key, value, [key]))}
</div>
);
};
export default InitialInfo;