This commit is contained in:
sjy
2026-07-03 18:07:58 +08:00
parent fca85b0157
commit c99f0f8c8f
8 changed files with 623 additions and 180 deletions
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Bof1g4Ez.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-ByI-1GQy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+14
View File
@@ -113,6 +113,20 @@ export async function optimizePrompt(
image_px: params.image_px || null,
});
}
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
@@ -62,7 +62,7 @@ import {
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser } from '../../api';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword } from '../../api';
import NotificationPopup from '../NotificationPopup';
import './AppLayout.css';
@@ -619,10 +619,18 @@ const AppLayout: React.FC = () => {
const handleChangePwd = async () => {
try {
await pwdForm.validateFields();
message.success('密码修改成功(演示)');
setPwdModalOpen(false); pwdForm.resetFields();
} catch { }
const values = await pwdForm.validateFields();
await changePassword(values.oldPwd, values.newPwd);
message.success('密码修改成功');
setPwdModalOpen(false);
pwdForm.resetFields();
} catch (error: any) {
if (error?.response?.data?.detail) {
message.error(error.response.data.detail);
} else if (error?.message) {
message.error(error.message);
}
}
};
const handleLogout = () => {
+168 -27
View File
@@ -25,7 +25,7 @@ import text from '../assets/testb.png';
import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api';
@@ -51,6 +51,7 @@ import {
DownloadOutlined,
ReloadOutlined,
AudioOutlined,
PauseOutlined,
} from '@ant-design/icons';
@@ -227,6 +228,38 @@ const AIChatPage: React.FC = () => {
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = 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 [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
@@ -278,6 +311,8 @@ const AIChatPage: React.FC = () => {
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
const [audioProgress, setAudioProgress] = useState(0);
// 附件详情悬浮窗状态
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
@@ -831,6 +866,9 @@ const AIChatPage: React.FC = () => {
}
}
console.log(mediaReferences);
// 创建用户消息对象
const newMessage: Message = {
id: '',
@@ -994,6 +1032,35 @@ const AIChatPage: React.FC = () => {
});
};
const getAudioDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const audio = document.createElement('audio');
audio.preload = 'metadata';
audio.onloadedmetadata = () => {
URL.revokeObjectURL(audio.src);
resolve(audio.duration);
};
audio.onerror = () => {
URL.revokeObjectURL(audio.src);
reject(new Error('无法获取音频时长'));
};
audio.src = URL.createObjectURL(file);
});
};
const handleAudioPlay = (url: string) => {
const audioUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`;
if (playingAudioUrl === audioUrl) {
const audio = document.getElementById('audio-player') as HTMLAudioElement;
if (audio) {
audio.pause();
}
setPlayingAudioUrl(null);
} else {
setPlayingAudioUrl(audioUrl);
}
};
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -1096,16 +1163,24 @@ const AIChatPage: React.FC = () => {
return false;
}
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImage = currentEngine?.maxImageCount ?? 4;
const maxVideo = currentEngine?.maxVideoCount ?? 1;
if (isAudio) {
const audioExt = file.name.split('.').pop()?.toLowerCase();
if (!['wav', 'mp3'].includes(audioExt || '')) {
message.error('音频仅支持wav和mp3格式');
return false;
}
}
if (mediaType === 'image' && (isVideo || isAudio)) {
message.error('图片模式仅支持上传图片');
return false;
}
if (isAudio && mediaType !== 'video') {
message.error('仅视频模式支持上传音频');
return false;
}
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
if (file.size / 1024 / 1024 > maxMB) {
@@ -1140,7 +1215,14 @@ const AIChatPage: React.FC = () => {
return false;
}
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
if (isAudio && audioCount >= maxAudio) {
message.error(`该引擎最多上传${maxAudio}个音频`);
return false;
}
let videoDuration = 0;
let audioDuration = 0;
if (isVideo) {
try {
videoDuration = await getVideoDuration(file);
@@ -1167,11 +1249,32 @@ const AIChatPage: React.FC = () => {
return false;
}
}
if (isAudio) {
try {
audioDuration = await getAudioDuration(file);
if (audioDuration < 2) {
message.error('音频素材最短不能少于 2 秒');
return false;
}
const existingAudioDuration = currentMedia
.filter((m) => m.type === 'audio')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingAudioDuration + audioDuration > 15) {
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)}`);
return false;
}
} catch {
message.error('无法获取音频信息,请检查文件是否损坏');
return false;
}
}
setUploading(true);
try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadImage : uploadVideo);
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
const res = await uploadFn(file);
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const newList = [...currentMedia, {
@@ -1179,7 +1282,8 @@ const AIChatPage: React.FC = () => {
type: mediaType,
url: res.url,
label: '',
...((isVideo || isAudio) && { duration: videoDuration }),
...(isVideo && { duration: videoDuration }),
...(isAudio && { duration: audioDuration }),
}];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
@@ -1287,10 +1391,11 @@ const AIChatPage: React.FC = () => {
document.body.removeChild(link);
};
const getAttachmentMediaType = (ref: any): 'image' | 'video' => {
const getAttachmentMediaType = (ref: any): 'image' | 'video' | 'audio' => {
const rawType = String(ref?.type || '').toLowerCase();
const rawUrl = String(ref?.url || ref?.name || '').toLowerCase();
if (rawType.includes('video') || /\.(mp4|mov|avi|webm|m4v)(\?|$)/.test(rawUrl)) return 'video';
if (rawType.includes('audio') || /\.(mp3|wav|ogg|aac|m4a)(\?|$)/.test(rawUrl)) return 'audio';
return 'image';
};
@@ -1320,7 +1425,8 @@ const AIChatPage: React.FC = () => {
if (!ref?.url) return;
const link = document.createElement('a');
link.href = buildAttachmentDownloadUrl(ref.url);
link.download = ref.name || (getAttachmentMediaType(ref) === 'image' ? 'image.png' : 'video.mp4');
const refType = getAttachmentMediaType(ref);
link.download = ref.name || (refType === 'image' ? 'image.png' : refType === 'video' ? 'video.mp4' : 'audio.mp3');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -1348,6 +1454,10 @@ const AIChatPage: React.FC = () => {
? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡'
: '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材';
const maxImage = maxImageCount;
const maxVideo = maxVideoCount;
const maxAudio = currentEngine?.maxAudioCount ?? 1;
return (
<Layout className="ai-create-page" style={{
margin: '-24px -32px -32px',
@@ -1357,6 +1467,14 @@ const AIChatPage: React.FC = () => {
overflow: 'hidden',
}}>
{/* 隐藏的音频播放器 */}
<audio
id="audio-player"
src={playingAudioUrl || ''}
autoPlay
onEnded={() => setPlayingAudioUrl(null)}
style={{ display: 'none' }}
/>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && (
<Sider
@@ -1380,7 +1498,7 @@ const AIChatPage: React.FC = () => {
{!collapsed && (
<Button
block
block
type="primary"
icon={<PlusOutlined />}
onClick={handleNewChat}
@@ -1389,6 +1507,7 @@ const AIChatPage: React.FC = () => {
</Button>
)}
{!collapsed && conversations.length > 0 && (
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
@@ -1527,7 +1646,6 @@ const AIChatPage: React.FC = () => {
paddingBottom: 18,
// background: 'linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0))',
background: '#fff',
// borderRadius: 22,
}}
>
@@ -1961,7 +2079,7 @@ const AIChatPage: React.FC = () => {
<div className="ai-reference-tray-scroll" style={{ display: 'flex', gap: 10, overflowX: 'auto', padding: '2px 2px 4px' }}>
{attachmentRefs.map((ref: any, idx: number) => {
const refType = getAttachmentMediaType(ref);
const label = ref.label || (ref.role === 'first_frame' ? '首帧' : ref.role === 'last_frame' ? '尾帧' : `${refType === 'image' ? '图片' : '视频'}${idx + 1}`);
const label = ref.label || (ref.role === 'first_frame' ? '首帧' : ref.role === 'last_frame' ? '尾帧' : `${refType === 'image' ? '图片' : refType === 'video' ? '视频' : '音频'}${idx + 1}`);
return (
<div
key={`${ref.url || ref.name || idx}-${idx}`}
@@ -1977,10 +2095,15 @@ const AIChatPage: React.FC = () => {
}}
>
<div
onClick={() => {
openAttachmentPreview(ref);
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
onClick={(e) => {
if (refType === 'audio') {
e.stopPropagation();
handleAudioPlay(ref.url);
} else {
openAttachmentPreview(ref);
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}
}}
style={{
position: 'relative',
@@ -1999,7 +2122,7 @@ const AIChatPage: React.FC = () => {
alt={ref.name || label}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
) : refType === 'video' ? (
<>
<video
src={buildAttachmentAssetUrl(ref.url)}
@@ -2013,6 +2136,14 @@ const AIChatPage: React.FC = () => {
</div>
</div>
</>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)' }}>
{playingAudioUrl === (ref.url.startsWith('http') ? ref.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`) ? (
<PauseOutlined style={{ fontSize: 24, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 24, color: '#fff' }} />
)}
</div>
)}
{/* <span style={{ position: 'absolute', left: 6, top: 6, padding: '2px 6px', borderRadius: 999, background: 'rgba(255,255,255,0.92)', color: '#8b5cf6', fontSize: 10, fontWeight: 800, boxShadow: '0 4px 10px rgba(31, 41, 55, 0.08)' }}>
{label}
@@ -2047,7 +2178,7 @@ const AIChatPage: React.FC = () => {
>
</button>
<button
{/* <button
onClick={(e) => downloadAttachmentRef(ref, e)}
title="下载"
style={{
@@ -2067,7 +2198,7 @@ const AIChatPage: React.FC = () => {
onMouseLeave={(e) => { e.currentTarget.style.background = '#FFFFFF'; e.currentTarget.style.color = '#667085'; }}
>
<DownloadOutlined style={{ fontSize: 12 }} />
</button>
</button> */}
</div>
</div>
);
@@ -2092,8 +2223,12 @@ const AIChatPage: React.FC = () => {
backdropFilter: 'blur(24px)',
}}
>
{/* <div>12312</div> */}
{/* 上方输入布局 */}
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
{/* 左侧附件区域 */}
<div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}>
@@ -2223,7 +2358,9 @@ const AIChatPage: React.FC = () => {
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}>
<div
style={{
@@ -2319,15 +2456,17 @@ const AIChatPage: React.FC = () => {
/>
) : (
<div
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('audio');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
onClick={(e) => {
e.stopPropagation();
handleAudioPlay(media.url);
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? (
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
)}
</div>
)}
{/* 右上角删除按钮 */}
@@ -2378,7 +2517,9 @@ const AIChatPage: React.FC = () => {
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}>
<div
style={{
+3 -1
View File
@@ -771,7 +771,8 @@ const HomePage: React.FC = () => {
{/* ========== 素材案例区域 ========== */}
<div className="animate-fadeInUp" style={{
{caseAssets.length > 0 && (
<div className="animate-fadeInUp" style={{
padding: '24px 28px',
borderRadius: 16,
background: '#fff',
@@ -901,6 +902,7 @@ const HomePage: React.FC = () => {
))}
</div>
</div>
)}
{/* ========== 预览弹窗 ========== */}
<Modal
+145 -9
View File
@@ -62,6 +62,71 @@ function InitialInfo() {
// 当前展开的步骤
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 openPreview = (url: string, type: 'image' | 'video') => {
console.log(url, type);
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 baseSteps = [
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
@@ -347,9 +412,12 @@ function InitialInfo() {
image_size: "2K"
}
gettwo(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 = () => {
@@ -362,9 +430,13 @@ function InitialInfo() {
}
gettwo(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);
});
}
@@ -385,7 +457,10 @@ function InitialInfo() {
getthree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
// 重新获取任务详情以更新数据
refreshTaskDetail();
message.info('正在生成视频提示词,请稍候...');
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
// 这里可以添加下一步的逻辑,比如调用接口等
};
@@ -398,8 +473,11 @@ function InitialInfo() {
getfour(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 agincreatevideo = () => {
@@ -410,6 +488,8 @@ function InitialInfo() {
getfour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成视频,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
});
@@ -503,10 +583,10 @@ function InitialInfo() {
<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)' }}>
<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
controls
src={taskDetail.material.materialVideoUrl}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -520,7 +600,7 @@ function InitialInfo() {
<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)' }}>
<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={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
@@ -537,7 +617,7 @@ function InitialInfo() {
<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)' }}>
<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=""
@@ -554,9 +634,9 @@ function InitialInfo() {
<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)' }}>
<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
controls
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -700,7 +780,7 @@ function InitialInfo() {
</Button>
<Button
onClick={() => { message.info('正在生成图片,请稍候...'); createimage(step.id); }}
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'}
@@ -1106,7 +1186,7 @@ function InitialInfo() {
<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={() => { message.info('正在生成视频提示词,请稍候...'); handleNextStep(step.id); }}
onClick={() => { handleNextStep(step.id); }}
disabled={step.status !== 'completed'}
>
@@ -1132,7 +1212,7 @@ function InitialInfo() {
/
</Button>
<Button
onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }}
onClick={() => { createvideo(step.id, step.engineId); }}
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'}
@@ -1433,6 +1513,62 @@ function InitialInfo() {
scroll={{ y: 350 }}
/>
</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>
);
}
+149 -11
View File
@@ -62,6 +62,44 @@ function InitialInfo() {
// 当前展开的步骤
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 baseSteps = [
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
@@ -83,7 +121,7 @@ function InitialInfo() {
// 当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')) {
@@ -335,12 +373,42 @@ function InitialInfo() {
}).catch((error: any) => {
});
};
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);
});
}
@@ -353,8 +421,11 @@ function InitialInfo() {
}
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 = () => {
@@ -367,9 +438,13 @@ function InitialInfo() {
}
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);
});
}
@@ -389,8 +464,11 @@ function InitialInfo() {
// 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);
});
// 这里可以添加下一步的逻辑,比如调用接口等
};
@@ -403,8 +481,12 @@ function InitialInfo() {
removefour(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 agincreatevideo = () => {
@@ -504,10 +586,10 @@ function InitialInfo() {
<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)' }}>
<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
controls
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -521,7 +603,7 @@ function InitialInfo() {
<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)' }}>
<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={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
@@ -537,7 +619,7 @@ function InitialInfo() {
<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)' }}>
<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=""
@@ -554,9 +636,9 @@ function InitialInfo() {
<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)' }}>
<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
controls
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -675,7 +757,7 @@ function InitialInfo() {
)}
</div>
</div>
<Button onClick={() => { message.info('正在生成图片提示词,请稍候...'); createone(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 onClick={() => { createone(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>
</div>
@@ -700,7 +782,7 @@ function InitialInfo() {
>
</Button>
<Button onClick={() => { message.info('正在生成图片,请稍候...'); 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 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>
@@ -1102,7 +1184,7 @@ function InitialInfo() {
<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={() => { message.info('正在生成视频提示词,请稍候...'); handleNextStep(step.id); }}
onClick={() => { handleNextStep(step.id); }}
disabled={step.status !== 'completed'}
>
@@ -1127,7 +1209,7 @@ function InitialInfo() {
>
/
</Button>
<Button onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }} 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 onClick={() => { createvideo(step.id, step.engineId); }} 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>
@@ -1387,6 +1469,62 @@ function InitialInfo() {
/>
</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={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={videoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
</React.Fragment>
);
}