2268 lines
144 KiB
TypeScript
2268 lines
144 KiB
TypeScript
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;
|