diff --git a/video-gen-app/src/pages/GenerateConver.tsx b/video-gen-app/src/pages/GenerateConver.tsx index 8c8cd17a..7483e1f0 100644 --- a/video-gen-app/src/pages/GenerateConver.tsx +++ b/video-gen-app/src/pages/GenerateConver.tsx @@ -1,6 +1,8 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; +// AI 创作对话框样式优化版 v11:白色极简高级风格,统一参考素材与历史附件展示。 + import { Layout, Button, @@ -164,6 +166,23 @@ const AIChatPage: React.FC = () => { const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null); const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false); const [mediaStackHovered, setMediaStackHovered] = useState(false); + const mediaStackCloseTimerRef = useRef(null); + const openMediaStackTray = useCallback(() => { + if (mediaStackCloseTimerRef.current) { + window.clearTimeout(mediaStackCloseTimerRef.current); + mediaStackCloseTimerRef.current = null; + } + setMediaStackHovered(true); + }, []); + const closeMediaStackTray = useCallback(() => { + if (mediaStackCloseTimerRef.current) { + window.clearTimeout(mediaStackCloseTimerRef.current); + } + mediaStackCloseTimerRef.current = window.setTimeout(() => { + setMediaStackHovered(false); + mediaStackCloseTimerRef.current = null; + }, 260); + }, []); // @ 提及相关状态 const [mentionVisible, setMentionVisible] = useState(false); @@ -279,9 +298,9 @@ const AIChatPage: React.FC = () => { // 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置 let config: any = {}; config = creditCalculationData.find((item: any) => - item.modelConfigId === countType && - item.genType === mediaType && - item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution) + item.modelConfigId === countType && + item.genType === mediaType && + item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution) ); if (!config) { @@ -324,11 +343,11 @@ const AIChatPage: React.FC = () => { let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio); // 传入视频积分 const inputVideoDuration = currentMedia - .filter((m) => m.type === 'video') - .reduce((sum, m) => sum + (m.duration || 0), 0); + .filter((m) => m.type === 'video') + .reduce((sum, m) => sum + (m.duration || 0), 0); if (inputVideoDuration > 0) { const inputVideoCost = Math.round( - ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1) + ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1) ); total += inputVideoCost; } @@ -359,6 +378,14 @@ const AIChatPage: React.FC = () => { // ==================== 副作用 ==================== // 滚动到底部的辅助函数 + useEffect(() => { + return () => { + if (mediaStackCloseTimerRef.current) { + window.clearTimeout(mediaStackCloseTimerRef.current); + } + }; + }, []); + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) return; @@ -438,69 +465,69 @@ const AIChatPage: React.FC = () => { // 初始化获取参数 - 只在组件挂载时执行一次 useEffect(() => { getEngine() - .then((data: any) => { - // console.log('引擎', data); + .then((data: any) => { + // console.log('引擎', data); - setEnginesele(data.engine); + setEnginesele(data.engine); - // 如果是视频模式,初始化视频参数选项 - if (data.engine.video && data.engine.video.length > 0) { - const defaultEngine = data.engine.video[0]; - - // 查找用户之前选择的引擎(如果存在) - const savedEngine = data.engine.video.find((e: any) => e.id === countType); - const targetEngine = savedEngine || defaultEngine; - - // 更新引擎选项为当前引擎支持的参数 - setEngineOptions({ - ratios: targetEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'], - resolutions: targetEngine.supportedResolutions || ['480p', '720p', '1080p'], - durations: targetEngine.supportedDurations || [5, 8, 10, 12, 15], - }); - - // 确保视频参数在引擎支持的范围内 - if (!targetEngine.supportedRatios?.includes(videoAspectRatio)) { - setVideoAspectRatio(targetEngine.supportedRatios?.[0] || '16:9'); + // 如果是视频模式,初始化视频参数选项 + if (data.engine.video && data.engine.video.length > 0) { + const defaultEngine = data.engine.video[0]; + + // 查找用户之前选择的引擎(如果存在) + const savedEngine = data.engine.video.find((e: any) => e.id === countType); + const targetEngine = savedEngine || defaultEngine; + + // 更新引擎选项为当前引擎支持的参数 + setEngineOptions({ + ratios: targetEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'], + resolutions: targetEngine.supportedResolutions || ['480p', '720p', '1080p'], + durations: targetEngine.supportedDurations || [5, 8, 10, 12, 15], + }); + + // 确保视频参数在引擎支持的范围内 + if (!targetEngine.supportedRatios?.includes(videoAspectRatio)) { + setVideoAspectRatio(targetEngine.supportedRatios?.[0] || '16:9'); + } + if (!targetEngine.supportedResolutions?.includes(videoResolution)) { + setVideoResolution(targetEngine.supportedResolutions?.[0] || '720p'); + } + if (!targetEngine.supportedDurations?.includes(videoDuration)) { + setVideoDuration(targetEngine.supportedDurations?.[0] || 5); + } + + // 如果之前没有选择过引擎(还是默认值),才设置默认引擎 + if (countType === '请选择') { + setCountType(targetEngine.id); + } } - if (!targetEngine.supportedResolutions?.includes(videoResolution)) { - setVideoResolution(targetEngine.supportedResolutions?.[0] || '720p'); + + let supportedSizes = (data as any).engine?.image?.[0]?.supported_sizes || {}; + let supportedResolutions: string[] = []; + let twokwidth: string[] = []; + let fourkwidth: string[] = []; + + for (let key in supportedSizes["2K"]) { + supportedResolutions.push(key); + twokwidth.push(supportedSizes["2K"][key]); } - if (!targetEngine.supportedDurations?.includes(videoDuration)) { - setVideoDuration(targetEngine.supportedDurations?.[0] || 5); + + const newRatioOptions = supportedResolutions.map((res: any) => ({ + value: res, + label: res, + })); + setRatioOptions(newRatioOptions); + + for (let key in supportedSizes["4K"]) { + fourkwidth.push(supportedSizes["4K"][key]); } - - // 如果之前没有选择过引擎(还是默认值),才设置默认引擎 - if (countType === '请选择') { - setCountType(targetEngine.id); - } - } - let supportedSizes = (data as any).engine?.image?.[0]?.supported_sizes || {}; - let supportedResolutions: string[] = []; - let twokwidth: string[] = []; - let fourkwidth: string[] = []; - - for (let key in supportedSizes["2K"]) { - supportedResolutions.push(key); - twokwidth.push(supportedSizes["2K"][key]); - } - - const newRatioOptions = supportedResolutions.map((res: any) => ({ - value: res, - label: res, - })); - setRatioOptions(newRatioOptions); - - for (let key in supportedSizes["4K"]) { - fourkwidth.push(supportedSizes["4K"][key]); - } - - const newWidthandHeight = [twokwidth, fourkwidth]; - setWidthandHeight(newWidthandHeight); - setResolutionOptions([ - { value: '2K', label: '高清 2K' }, - { value: '4K', label: '超清 4K' }, - ]); + const newWidthandHeight = [twokwidth, fourkwidth]; + setWidthandHeight(newWidthandHeight); + setResolutionOptions([ + { value: '2K', label: '高清 2K' }, + { value: '4K', label: '超清 4K' }, + ]); @@ -509,9 +536,9 @@ const AIChatPage: React.FC = () => { - }) - .catch(() => { - }); + }) + .catch(() => { + }); // getCreditRatios() // .then((data: any) => { // // console.log('积分', data); @@ -563,7 +590,7 @@ const AIChatPage: React.FC = () => { // }) // .catch(() => { }); }, []); - + // 轮询逻辑 - 当有任务在生成时,每5秒查询一次状态 useEffect(() => { @@ -614,8 +641,8 @@ const AIChatPage: React.FC = () => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( - !target.closest(".image-settings-popover") && - !target.closest(".image-settings-trigger") + !target.closest(".image-settings-popover") && + !target.closest(".image-settings-trigger") ) { setShowImageSettingsModal(false); } @@ -629,8 +656,8 @@ const AIChatPage: React.FC = () => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( - !target.closest(".image-settings-popover") && - !target.closest(".image-settings-trigger") + !target.closest(".image-settings-popover") && + !target.closest(".image-settings-trigger") ) { setShowVideoSettingsModal(false); } @@ -642,8 +669,8 @@ const AIChatPage: React.FC = () => { // 监听 blindex 状态变化 useEffect(() => { if ( - widthandheight[fblindex] && - widthandheight[fblindex][blindex] !== undefined + widthandheight[fblindex] && + widthandheight[fblindex][blindex] !== undefined ) { setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4))); setHeight(Number(widthandheight[fblindex][blindex].substring(5))); @@ -652,8 +679,8 @@ const AIChatPage: React.FC = () => { useEffect(() => { if ( - widthandheight[fblindex] && - widthandheight[fblindex][blindex] !== undefined + widthandheight[fblindex] && + widthandheight[fblindex][blindex] !== undefined ) { setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4))); setHeight(Number(widthandheight[fblindex][blindex].substring(5))); @@ -734,9 +761,9 @@ const AIChatPage: React.FC = () => { // 如果删除的是当前选中的对话,切换到其他对话 if (currentConversationId === conversationId) { setCurrentConversationId( - conversations[0]?.id === conversationId - ? conversations[1]?.id || null - : conversations[0]?.id || null + conversations[0]?.id === conversationId + ? conversations[1]?.id || null + : conversations[0]?.id || null ); } // 显示成功提示 @@ -950,7 +977,7 @@ const AIChatPage: React.FC = () => { }); }; - const handleUpload = async (file: File) => { + const handleUpload = async (file: File, frameTarget?: 'first' | 'last') => { const isImage = file.type.startsWith('image/'); const isVideo = file.type.startsWith('video/'); @@ -963,7 +990,9 @@ const AIChatPage: React.FC = () => { message.error('图片大小不能超过10MB'); return false; } - if (!uploadTarget) { + const effectiveUploadTarget = frameTarget || uploadTarget; + if (!effectiveUploadTarget) { + message.error('请选择首帧或尾帧上传位置'); return false; } @@ -974,9 +1003,9 @@ const AIChatPage: React.FC = () => { name: file.name, type: 'image', url: res.url, - role: uploadTarget === 'first' ? 'first_frame' : 'last_frame', + role: effectiveUploadTarget === 'first' ? 'first_frame' : 'last_frame', }; - if (uploadTarget === 'first') { + if (effectiveUploadTarget === 'first') { setFirstFrame(mediaRef); } else { setLastFrame(mediaRef); @@ -1033,8 +1062,8 @@ const AIChatPage: React.FC = () => { return false; } const existingVideoDuration = currentMedia - .filter((m) => m.type === 'video') - .reduce((sum, m) => sum + (m.duration || 0), 0); + .filter((m) => m.type === 'video') + .reduce((sum, m) => sum + (m.duration || 0), 0); if (existingVideoDuration + videoDuration > 15) { message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`); return false; @@ -1090,7 +1119,7 @@ const AIChatPage: React.FC = () => { const cursorPos = textarea.selectionStart; const textBeforeCursor = value.slice(0, cursorPos); const atMatch = textBeforeCursor.match(/@([^@\s]*)$/); - + if (atMatch && currentMedia.length > 0) { setMentionVisible(true); return true; @@ -1120,21 +1149,21 @@ const AIChatPage: React.FC = () => { const insertMention = (label: string) => { const textarea = mentionInputRef.current?.resizableTextArea?.textArea; if (!textarea) return; - + const cursorPos = textarea.selectionStart; const textBefore = inputValue.slice(0, cursorPos); const textAfter = inputValue.slice(cursorPos); - + const atIndex = textBefore.lastIndexOf('@'); if (atIndex === -1) { setMentionVisible(false); return; } - + const newValue = textBefore.slice(0, atIndex) + `@${label} ` + textAfter; setInputValue(newValue); setMentionVisible(false); - + setTimeout(() => { const pos = atIndex + label.length + 2; textarea.focus(); @@ -1164,412 +1193,468 @@ const AIChatPage: React.FC = () => { document.body.removeChild(link); }; + const getAttachmentMediaType = (ref: any): 'image' | 'video' => { + 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'; + return 'image'; + }; + + const buildAttachmentAssetUrl = (url: string): string => { + if (!url) return ''; + if (/^https?:\/\//i.test(url)) return url; + return `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`; + }; + + const buildAttachmentDownloadUrl = (url: string): string => { + const assetUrl = buildAttachmentAssetUrl(url); + if (!assetUrl) return ''; + return `${assetUrl}${assetUrl.includes('?') ? '&' : '?'}download=1`; + }; + + const openAttachmentPreview = (ref: any) => { + if (!ref?.url) return; + setAttachmentPreviewUrl(ref.url); + setAttachmentPreviewType(getAttachmentMediaType(ref)); + setAttachmentPreviewName(ref.name || '附件'); + setAttachmentPreviewVisible(true); + }; + + const downloadAttachmentRef = (ref: any, e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + 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'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + // ==================== 渲染 ==================== + const isFirstLastFrameComposer = mediaType === 'video' && referenceMode === 'first_last_frame'; + const composerCanSend = isFirstLastFrameComposer + ? Boolean(inputValue.trim() || firstFrame) + : Boolean(inputValue.trim() || currentMedia.length > 0); + const composerModeLabel = mediaType === 'image' + ? '图片生成' + : isFirstLastFrameComposer + ? '首尾帧视频' + : '视频生成'; + const composerPlaceholder = mediaType === 'image' + ? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。' + : isFirstLastFrameComposer + ? '描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。' + : '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、音、视频。例:@图片1 模仿 @视频1 的动作,音色参考 @音频1。'; + const composerHelperText = mediaType === 'image' + ? '图片参考 · 适合海报、产品图、场景图与风格图生成' + : isFirstLastFrameComposer + ? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡' + : '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材'; + return ( - - {/* 左侧边栏 - 对话列表(已隐藏,保留代码) */} - {false && ( - -
- - )} - - {!collapsed && conversations.length > 0 && ( -
- {conversations.map((conversation) => ( -
handleSelectChat(conversation.id)} - style={{ - display: 'flex', - alignItems: 'center', - padding: '10px 12px', - marginBottom: 4, - borderRadius: 8, - cursor: 'pointer', - background: currentConversationId === conversation.id ? '#f0f5ff' : 'transparent', - border: currentConversationId === conversation.id ? '1px solid #e0e8ff' : 'none', - transition: 'all 0.2s', - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : '#fafafa'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : 'transparent'; - }} - > -
-

- {conversation.title} -

-

- {conversation.lastMessage || '暂无消息'} -

-
- handleDeleteChat(conversation.id)} - okText="确定" - cancelText="取消" - > -
- ))} -
- )} -
-
- )} - - {/* 主内容区 */} - - {/* 头部 - 显示对话标题和模型信息 */} -
-
-
-
-

- AI创作 -

-

- 输入想法、剧本或上传参考,智能生成视频/图片 -

-
-
-
-
- - {/* 消息区域 */} - - {/* 空状态 - 没有对话或当前对话没有消息时显示 */} - {gen_list.length === 0 && ( -
-
+ {/* 左侧边栏 - 对话列表(已隐藏,保留代码) */} + {false && ( + - -
-

- 你好,想创作什么? -

-

- 输入想法、剧本或上传参考,开始你的创作之旅 -

-
- )} - - {/* 消息列表 - gen_list 有数据时显示 */} - {gen_list.length > 0 && ( -
- {/* 加载更多按钮 - 在列表顶部 */} -
- {gen_list.length >= Totalnumber ? ( - 消息全部加载 - ) : ( - +
+ )} -
- {/* 遍历消息列表 */} - {(() => { - const msgApi = message; - return gen_list.map((msg) => ( -
-
- {/* 头像 */} -
- -
- {/* 消息内容 */} -
- {/* 时间戳和参数信息 */} - - - {/* 消息气泡 */} -
-
- {msg.createdAt?.replace('T', ' ').split('.')[0]} - - {msg.genType === 'image' ? '图片生成' : '视频生成'} - -
- {/* 附件详情 - 右上角 */} - {msg.mediaReferences && msg.mediaReferences.length > 0 && ( -
- { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情 -
- )} - {/* 操作按钮 - 右下角 */} -
- + > +
+

+ {conversation.title} +

+

+ {conversation.lastMessage || '暂无消息'} +

+
{ - try { - await deleteHistory(msg.id); - msgApi.success('删除成功'); - // 直接在本地列表中删除对应数据 - setGen_list(prev => prev.filter(item => item.id !== msg.id)); - setTotalnumber(prev => prev - 1); - } catch (error: any) { - - const errorMsg = error?.response?.data?.message || error?.message || '删除失败'; - msgApi.error(errorMsg); - } - }} - okText="确定" - cancelText="取消" + title="确定删除此对话?" + onConfirm={() => handleDeleteChat(conversation.id)} + okText="确定" + cancelText="取消" > -
+ ))} +
+ )} +
+ + )} + + {/* 主内容区 */} + + {/* 头部 - 显示对话标题和模型信息 */} +
+
+
+
+

+ AI创作 +

+

+ 输入想法、剧本或上传参考,智能生成视频/图片 +

+
+
+
+
+ + {/* 消息区域 */} + + {/* 空状态 - 没有对话或当前对话没有消息时显示 */} + {gen_list.length === 0 && ( +
+
+ +
+

+ 你好,想创作什么? +

+

+ 输入想法、剧本或上传参考,开始你的创作之旅 +

+
+ )} + + {/* 消息列表 - gen_list 有数据时显示 */} + {gen_list.length > 0 && ( +
+ {/* 加载更多按钮 - 在列表顶部 */} +
+ {gen_list.length >= Totalnumber ? ( + 消息全部加载 + ) : ( + + )} +
+ {/* 遍历消息列表 */} + {(() => { + const msgApi = message; + return gen_list.map((msg) => ( +
+
+ {/* 头像 */} +
{ - e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'; - e.currentTarget.style.color = '#ef4444'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)'; - e.currentTarget.style.color = '#6366f1'; - }} - > - - 删除 - - -
+ > + +
- {/* 文本内容 */} - {/*
+ {/* 时间戳和参数信息 */} + + + {/* 消息气泡 */} +
+
+ {msg.createdAt?.replace('T', ' ').split('.')[0]} + + {msg.genType === 'image' ? '图片生成' : '视频生成'} + +
+ {/* 附件详情 - 右上角 */} + {msg.mediaReferences && msg.mediaReferences.length > 0 && ( +
+ { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情 +
+ )} + {/* 操作按钮 - 右下角 */} +
+ + { + try { + await deleteHistory(msg.id); + msgApi.success('删除成功'); + // 直接在本地列表中删除对应数据 + setGen_list(prev => prev.filter(item => item.id !== msg.id)); + setTotalnumber(prev => prev - 1); + } catch (error: any) { + + const errorMsg = error?.response?.data?.message || error?.message || '删除失败'; + msgApi.error(errorMsg); + } + }} + okText="确定" + cancelText="取消" + > + + +
+ + {/* 文本内容 */} + {/*
{ setExpandedPrompts(prev => { @@ -1609,1764 +1694,1898 @@ const AIChatPage: React.FC = () => {
*/} - {/* 根据 status 显示不同内容 */} - {(msg.status === 'generating' || msg.status === 'failed' || msg.status === 'completed') && ( -
-
- {msg.status === 'generating' ? ( - <> -
- {/*
*/} -
-
-
-
-
-
- 生成中... + {/* 根据 status 显示不同内容 */} + {(msg.status === 'generating' || msg.status === 'failed' || msg.status === 'completed') && ( +
-
-
-
+ }}> +
+ {msg.status === 'generating' ? ( + <> +
+ {/*
*/} +
+
+
+
+
+
+ 生成中... + + {/*
+
+
+
*/} -
- - ) : msg.status === 'failed' ? ( -
-
- -
- 生成失败(积分已退) - {msg.errorMessage && ( - {msg.errorMessage} - )} -
- ) : ( - <> - {msg.genType === 'image' ? ( - {msg.name} { setPreviewUrl(msg.imageUrl); setPreviewType('image'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} /> - ) : ( - <> - {msg.name} { setPreviewUrl(msg.videoUrl); setPreviewType('video'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} /> -
- +
+ + ) : msg.status === 'failed' ? ( +
+
+ +
+ 生成失败(积分已退) + {msg.errorMessage && ( + {msg.errorMessage} + )} +
+ ) : ( + <> + {msg.genType === 'image' ? ( + {msg.name} { setPreviewUrl(msg.imageUrl); setPreviewType('image'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} /> + ) : ( + <> + {msg.name} { setPreviewUrl(msg.videoUrl); setPreviewType('video'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} /> +
+ +
+ + )} + + )} +
+
+
+

{msg.originalPrompt}

- - )} - +
+ {msg.engineSnapshot.name} + {msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`} + 消耗积分:{msg.creditsCost} + +
+ +
+
+ )}
-
-
-

{msg.originalPrompt}

-
-
- {msg.engineSnapshot.name} - {msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`} - 消耗积分:{msg.creditsCost} - -
- -
+ +
- - )} + +
+ )) + })()} -
- -
-
- )) - })()} - - - {/* 消息列表底部标记 - 用于自动滚动 */} -
-
- )} - - {/* 附件详情悬浮窗 - 包含遮罩层 */} - {attachmentPopupVisible && attachmentPopupMessageId && ( - <> - {/* 遮罩层 - 点击关闭悬浮窗 */} -
{ - setAttachmentPopupVisible(false); - setAttachmentPopupMessageId(null); - }} - /> - {/* 悬浮窗内容 */} -
e.stopPropagation()} - > -
- 附件列表 - + {/* 消息列表底部标记 - 用于自动滚动 */} +
- {gen_list.find((msg: any) => msg.id === attachmentPopupMessageId)?.mediaReferences?.map((ref: any, idx: number) => ( -
{ - setAttachmentPreviewUrl(ref.url); - setAttachmentPreviewType(ref.url.includes('.mp4') || ref.url.includes('.mov') || ref.url.includes('.avi') || ref.url.includes('.video') ? 'video' : 'image'); - setAttachmentPreviewName(ref.name); - setAttachmentPreviewVisible(true); - // 关闭附件悬浮窗,避免层级覆盖问题 - setAttachmentPopupVisible(false); - setAttachmentPopupMessageId(null); - }} - style={{ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '8px 12px', - borderRadius: 6, - cursor: 'pointer', - fontSize: 13, - color: '#333', - transition: 'background 0.2s' - }} - onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - > - {ref.type === 'image' ? ( - - ) : ( - - )} - {ref.name} -
- ))} -
- - )} + )} - {/* 输入区域 - 始终显示 */} -
- {/* 上方输入布局 */} -
- {/* 左侧附件区域 */} -
- {/* 首尾帧模式 */} - {mediaType === 'video' && referenceMode === 'first_last_frame' ? ( -
- {/* 首帧 */} -
- 首帧 - {firstFrame ? ( -
- {firstFrame.name} { - setAttachmentPreviewUrl(firstFrame.url); - setAttachmentPreviewType('image'); - setAttachmentPreviewName(firstFrame.name); - setAttachmentPreviewVisible(true); - }} - style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(99, 102, 241, 0.3)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }} - /> - -
- ) : ( - { setUploadTarget('first'); return handleUpload(file); }} - > -
{ - e.currentTarget.style.borderColor = '#6366f1'; - e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.08)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)'; - e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)'; - }} - > - - 首帧 -
-
- )} -
- - {/* 调换按钮 */} - - - {/* 尾帧 */} -
- 尾帧 - {lastFrame ? ( -
- {lastFrame.name} { - setAttachmentPreviewUrl(lastFrame.url); - setAttachmentPreviewType('image'); - setAttachmentPreviewName(lastFrame.name); - setAttachmentPreviewVisible(true); - }} - style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(139, 92, 246, 0.3)', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.15)' }} - /> - -
- ) : ( - { setUploadTarget('last'); return handleUpload(file); }} - > -
{ - e.currentTarget.style.borderColor = '#8b5cf6'; - e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.3)'; - e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)'; - }} - > - - 尾帧 -
-
- )} -
-
- ) : ( + {/* 附件详情悬浮窗 - 参考素材 hover 块风格 */} + {attachmentPopupVisible && attachmentPopupMessageId && (() => { + const currentAttachmentMessage = gen_list.find((msg: any) => msg.id === attachmentPopupMessageId); + const attachmentRefs = currentAttachmentMessage?.mediaReferences || []; + return ( <> - {currentMedia.length > 0 ? ( -
setMediaStackHovered(true)} - onMouseLeave={() => setMediaStackHovered(false)} - > -
- {currentMedia.slice(0, 4).map((media, idx) => { - const reversedIdx = currentMedia.slice(0, 4).length - 1 - idx; - const offsetX = mediaStackHovered ? idx * 6 : idx * 3; - const offsetY = mediaStackHovered ? idx * 6 : idx * 2; - return ( -
-
- {media.type === 'image' ? ( - {media.name} { - setAttachmentPreviewUrl(media.url); - setAttachmentPreviewType('image'); - setAttachmentPreviewName(media.name); - setAttachmentPreviewVisible(true); - }} - style={{ width: 50, height: 58, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.95)', boxShadow: '0 2px 8px rgba(15, 23, 42, 0.12)' }} - /> - ) : ( -
-
- ); - })} - {currentMedia.length > 4 && ( -
- +{currentMedia.length - 4} -
- )} + {/* 遮罩层 - 点击关闭悬浮窗 */} +
{ + setAttachmentPopupVisible(false); + setAttachmentPopupMessageId(null); + }} + /> +
e.stopPropagation()} + > +
+
+ 参考附件 + 点击素材查看,或直接下载原文件
-
- ) : null} - - {/* 上传按钮 - 非首尾帧模式且还有上传空间时显示 */} - {!(mediaType === 'video' && referenceMode === 'first_last_frame') && ( -
0 ? 6 : 0, width: '100%', maxWidth: 58, display: 'flex', justifyContent: 'center' }}> - - m.type === 'image').length}/${maxImageCount}` - : `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}` - }> -
+ {attachmentRefs.length}/12 + +
+
+ +
+ {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}`); + return ( +
+
{ + openAttachmentPreview(ref); + setAttachmentPopupVisible(false); + setAttachmentPopupMessageId(null); + }} + style={{ + position: 'relative', + width: '100%', + height: 70, + borderRadius: 12, + overflow: 'hidden', + cursor: 'pointer', + background: '#F7F8FA', + border: '1px solid rgba(231, 234, 240, 0.9)', + }} + > + {refType === 'image' ? ( + {ref.name + ) : ( + <> +
+ +
+ {ref.name || label} +
+ +
+ + +
+
+ ); + })} +
+
+ + ); + })()} + + + {/* 输入区域 - 始终显示 */} +
+ {/* 上方输入布局 */} +
+ {/* 左侧附件区域 */} +
+ {/* 首尾帧模式 */} + {mediaType === 'video' && referenceMode === 'first_last_frame' ? ( +
+ {/* 首帧 */} +
+
+ 首帧 + 必传 +
+ {firstFrame ? ( +
+ {firstFrame.name} { + setAttachmentPreviewUrl(firstFrame.url); + setAttachmentPreviewType('image'); + setAttachmentPreviewName(firstFrame.name); + setAttachmentPreviewVisible(true); + }} + style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(75, 85, 99, 0.2)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.08)' }} + /> + +
+ ) : ( + handleUpload(file, 'first')}> +
{ e.currentTarget.style.borderColor = '#6B7280'; e.currentTarget.style.backgroundColor = 'rgba(75, 85, 99, 0.08)'; }} + onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.28)'; e.currentTarget.style.backgroundColor = 'rgba(75, 85, 99, 0.04)'; }} + > + + 上传首帧 +
+
+ )} +
+ + {/* 调换按钮 */} + + + {/* 尾帧 */} +
+
+ 尾帧 + 可选 +
+ {lastFrame ? ( +
+ {lastFrame.name} { + setAttachmentPreviewUrl(lastFrame.url); + setAttachmentPreviewType('image'); + setAttachmentPreviewName(lastFrame.name); + setAttachmentPreviewVisible(true); + }} + style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(75, 85, 99, 0.2)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.08)' }} + /> + +
+ ) : ( + handleUpload(file, 'last')}> +
{ e.currentTarget.style.borderColor = '#6B7280'; e.currentTarget.style.backgroundColor = 'rgba(75, 85, 99, 0.08)'; }} + onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.24)'; e.currentTarget.style.backgroundColor = 'rgba(75, 85, 99, 0.04)'; }} + > + + 上传尾帧 +
+
+ )} +
+
) : ( + <> + {currentMedia.length > 0 ? ( +
- {uploading ? ( - - ) : ( - <> - - 参考内容 - +
+ {currentMedia.slice(0, 4).map((media, idx) => { + const reversedIdx = currentMedia.slice(0, 4).length - 1 - idx; + const offsetX = mediaStackHovered ? idx * 7 : idx * 3; + const offsetY = mediaStackHovered ? idx * 5 : idx * 2; + return ( +
+
+ {media.type === 'image' ? ( + {media.name} { + setAttachmentPreviewUrl(media.url); + setAttachmentPreviewType('image'); + setAttachmentPreviewName(media.name); + setAttachmentPreviewVisible(true); + }} + style={{ width: 50, height: 54, objectFit: 'cover', borderRadius: 11, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.98)', boxShadow: '0 8px 18px rgba(31, 41, 55, 0.14)' }} + /> + ) : ( +
+
+ ); + })} + {currentMedia.length > 4 && ( +
+ +{currentMedia.length - 4} +
+ )} +
+ {mediaStackHovered && currentMedia.length > 0 && ( +
+ )} + {mediaStackHovered && currentMedia.length > 0 && ( +
+
+ 已上传参考素材 + {currentMedia.length}/12 +
+
+ {currentMedia.map((media, idx) => ( +
+ {media.type === 'image' ? ( + {media.name} { + setAttachmentPreviewUrl(media.url); + setAttachmentPreviewType('image'); + setAttachmentPreviewName(media.name); + setAttachmentPreviewVisible(true); + }} + style={{ width: 58, height: 62, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '1px solid #E7EAF0', boxShadow: '0 6px 14px rgba(31, 41, 55, 0.08)' }} + /> + ) : ( +
+ ))} +
+
)}
- - -
- )} - - )} -
+ ) : null} - {/* 右侧输入区域 */} -
- {/* 输入框区域 */} -
- {/* 文本输入框 */} -