Files
video-gen/video-gen-app/src/pages/GenerateConver.tsx
T
2026-07-11 13:00:06 +08:00

4576 lines
204 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
// AI 创作对话框样式优化版 v12:白色极简高级风格,选项按钮、标题、头像与关键字统一使用 #8b5cf6。
import {
Layout,
Button,
Input,
Select,
message,
Space,
Typography,
Tooltip,
Popconfirm,
Modal,
App,
} from 'antd';
import bg1 from '../assets/bg1.png';
import bg2 from '../assets/bg2.png';
import bg3 from '../assets/bg3.png';
import text from '../assets/testb.png';
import UploadSelector from '../components/UploadSelector';
import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api';
import { useAppStore } from '../store/useAppStore';
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
import {
PlusOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
SendOutlined,
DeleteOutlined,
CloseOutlined,
RobotOutlined,
LoadingOutlined,
PictureOutlined,
VideoCameraOutlined,
CaretDownOutlined,
SwapOutlined,
WarningOutlined,
SettingOutlined,
LayoutOutlined,
ArrowUpOutlined,
DownloadOutlined,
ReloadOutlined,
AudioOutlined,
PauseOutlined,
} from '@ant-design/icons';
const { Header, Sider, Content } = Layout;
// 解构Input组件
const { TextArea } = Input;
// 解构Select组件
const { Option } = Select;
// 解构Typography组件
const { Text } = Typography;
interface MediaReference {
name: string;
type: 'image' | 'video' | 'audio';
url: string;
duration?: number;
role?: string;
label?: string;
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
upload_resource_id?: string;
}
interface Message {
id: string;
original_prompt: string;
gen_type: string;
idempotency_key: string;
media_references?: MediaReference[];
image_size?: string;
image_proportion?: string;
image_px?: string;
duration?: number;
ratio?: string;
resolution?: string;
timestamp?: string;
engine_id: string;
}
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: string;
messages: any;
}
const mockUpload = (file: File): Promise<{ url: string }> => {
return new Promise((resolve) => {
setTimeout(() => {
// 使用URL.createObjectURL创建本地文件预览URL
const url = URL.createObjectURL(file);
resolve({ url });
}, 500);
});
};
const AIChatPage: React.FC = () => {
// ==================== 状态定义 ====================
const { message: antdMessage } = App.useApp();
const location = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(false);
const [currentConversationId, setCurrentConversationId] = useState<string | null>('1');
const [conversations, setConversations] = useState<Conversation[]>([
]);
// 从 store 读取生成配置状态(包括输入框内容)
const {
mediaType,
countType,
selectedRatio,
selectedResolution,
width,
height,
videoDuration,
videoAspectRatio,
videoResolution,
engineOptions,
enginesele,
inputValue,
setMediaType,
setCountType,
setSelectedRatio,
setSelectedResolution,
setWidth,
setHeight,
setVideoDuration,
setVideoAspectRatio,
setVideoResolution,
setEngineOptions,
setEnginesele,
setInputValue,
currentMedia,
setCurrentMedia,
} = useAppStore();
// 获取当前选中引擎的媒体上传限制
const currentEngineList = mediaType === 'image' ? enginesele?.image : enginesele?.video;
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImageCount = currentEngine?.maxImageCount ?? 4;
const maxVideoCount = currentEngine?.maxVideoCount ?? 1;
const [uploading, setUploading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [referenceMode, setReferenceMode] = useState<'universal' | 'first_last_frame'>('universal');
const [firstFrame, setFirstFrame] = useState<MediaReference | null>(null);
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
const [privateAssetPickerLibraryType, setPrivateAssetPickerLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const [mediaStackHovered, setMediaStackHovered] = useState(false);
const mediaStackCloseTimerRef = useRef<number | null>(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);
const mentionInputRef = useRef<any>(null);
const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 });
// 数字转中文数字(一、二、三、四)
const numberToChinese = (num: number): string => {
const map = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
if (num <= 10) return map[num];
if (num < 20) return '十' + map[num % 10];
if (num < 100) {
const tens = Math.floor(num / 10);
const ones = num % 10;
return map[tens] + '十' + (ones ? map[ones] : '');
}
return String(num);
};
// 根据媒体列表生成标签(图片一、图片二、视频一、视频二...)
const generateMediaLabels = (media: { type: 'image' | 'video' | 'audio' }[]) => {
let imgCount = 0;
let vidCount = 0;
let audCount = 0;
return media.map((m) => {
if (m.type === 'image') {
imgCount++;
return `图片${imgCount}`;
} else if (m.type === 'video') {
vidCount++;
return `视频${vidCount}`;
} else {
audCount++;
return `音频${audCount}`;
}
});
};
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>('');
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());
// 从URL中提取exp时间戳(支持相对路径和完整URL)
const extractExpTimestamp = (url: string): number | null => {
if (!url) return null;
try {
// 尝试作为完整URL解析
const urlObj = new URL(url);
const expStr = urlObj.searchParams.get('exp');
if (expStr) {
return parseInt(expStr, 10);
}
return null;
} catch {
// 如果完整URL解析失败,尝试解析相对路径中的查询参数
try {
// 查找 ? 后面的部分
const queryStart = url.indexOf('?');
if (queryStart !== -1) {
const queryString = url.substring(queryStart + 1);
const params = new URLSearchParams(queryString);
const expStr = params.get('exp');
if (expStr) {
return parseInt(expStr, 10);
}
}
return null;
} catch {
return null;
}
}
};
// 检查媒体是否过期
const isMediaExpired = (url: string): boolean => {
const expTimestamp = extractExpTimestamp(url);
if (!expTimestamp) {
return false; // 没有exp参数,视为不过期
}
const currentTimestamp = Math.floor(Date.now() / 1000);
return currentTimestamp > expTimestamp;
};
// 附件预览弹窗状态(独立弹窗)
const [attachmentPreviewVisible, setAttachmentPreviewVisible] = useState<boolean>(false);
const [attachmentPreviewUrl, setAttachmentPreviewUrl] = useState<string>('');
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
// 弹窗打开时自动播放视频,关闭时停止
useEffect(() => {
if (attachmentPreviewVisible && attachmentPreviewType === 'video' && attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current;
v.currentTime = 0;
v.muted = false;
const playPromise = v.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
// 自动播放被阻止时静音重试
v.muted = true;
v.play().catch(() => {});
});
}
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current;
v.pause();
v.muted = true;
v.currentTime = 0;
}
}, [attachmentPreviewVisible, attachmentPreviewType, attachmentPreviewUrl]);
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
const [audioProgress, setAudioProgress] = useState(0);
// 附件详情悬浮窗状态
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
const [attachmentPopupMessageId, setAttachmentPopupMessageId] = useState<string | null>(null);
const [attachmentPopupPosition, setAttachmentPopupPosition] = useState({ x: 0, y: 0 });
const [showImageSettingsModal, setShowImageSettingsModal] = useState(false);
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
const [ratioOptions, setRatioOptions] = useState([]);
const [resolutionOptions, setResolutionOptions] = useState([]);
const [currentEngineSupportedSizes, setCurrentEngineSupportedSizes] = useState<Record<string, Record<string, string>>>({});
const [showEngineModal, setShowEngineModal] = useState(false);
const [showMediaTypeModal, setShowMediaTypeModal] = useState(false);
const [creditRatios, setCreditRatios] = useState<any[]>([]);
const [cimage, setCimage] = useState<any[]>([]);
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
// 获取预估积分 - 根据引擎ID、类型和分辨率计算
const getEstimatedCredits = (): number => {
// 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置
let config: any = {};
config = creditCalculationData.find((item: any) =>
item.modelConfigId === countType &&
item.genType === mediaType &&
item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution)
);
if (!config) {
if (mediaType === 'video') {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1.3,
inputVideoRatio: 1.3,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 15,
inputImageRatio: 1,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.0,
};
if (videoResolution === '1080p') {
config.ratio = 1.3;
} else if (videoResolution === '720p') {
config.ratio = 1.3;
} else if (videoResolution === '480p') {
config.ratio = 1.3;
}
}
else {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 0.1,
baseCredits: 2,
ratio: 3,
inputImageRatio: 1,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.0,
};
if (selectedResolution === '2K') {
config.ratio = 3;
} else if (selectedResolution === '4K') {
config.ratio = 3;
} else if (selectedResolution === '1K') {
config.ratio = 3;
}
}
}
// 根据配置计算积分(保留两位小数,不做取整)
if (mediaType === 'video') {
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
// 传入视频积分
const inputVideoDuration = currentMedia
.filter((m) => m.type === 'video')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (inputVideoDuration > 0) {
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
total += inputVideoCost;
}
// 传入图片积分
const inputImageCount = currentMedia
.filter((m) => m.type === 'image')
.length;
if (inputImageCount > 0) {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
} else {
// 图片:baseCredits × ratio
let total = config.baseCredits * config.ratio;
// 传入图片积分
const inputImageCount = currentMedia
.filter((m) => m.type === 'image')
.length;
if (inputImageCount > 0) {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
}
};
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pollingRef = useRef<number | null>(null);
const isFirstLoadRef = useRef<boolean>(true);
const isInitialLoadDone = useRef(false);
const lastAutoScrollTime = useRef(0);
const currentConversation = conversations.find((c) => c.id === currentConversationId);
const [gen_list, setGen_list] = useState<any>([]);
const [Pagebreak, setPagebreak] = useState<any>({
page: 1,
pageSize: 20,
});
const [Totalnumber, setTotalnumber] = useState<any>(0);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// ==================== 副作用 ====================
useEffect(() => {
const state = location.state as any;
if (state) {
if (state.generationPrompt) {
setInputValue(state.generationPrompt);
}
if (state.mediaReferences && Array.isArray(state.mediaReferences) && state.mediaReferences.length > 0) {
const mediaItems: MediaReference[] = state.mediaReferences.map((ref: any) => {
const url = ref.url || ref.resourceUrl || ref.previewUrl || '';
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`;
return {
...ref,
name: ref.name || `参考${ref.type}`,
type: ref.type === 'video' ? 'video' : ref.type === 'audio' ? 'audio' : 'image',
url: fullUrl,
duration: ref.duration || 0,
label: ref.label || '',
};
});
setCurrentMedia(mediaItems);
}
if (state.aspectRatio) {
setVideoAspectRatio(state.aspectRatio);
}
window.history.replaceState({}, '', window.location.pathname);
}
}, [location.state]);
// 滚动到底部的辅助函数
useEffect(() => {
return () => {
if (mediaStackCloseTimerRef.current) {
window.clearTimeout(mediaStackCloseTimerRef.current);
}
};
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
lastAutoScrollTime.current = Date.now();
const doScroll = () => {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
};
requestAnimationFrame(doScroll);
}, []);
// 初次打开或页面刷新时自动滚动到底部(只在初始加载时执行一次)
useEffect(() => {
if (gen_list.length > 0 && !isInitialLoadDone.current) {
const timer = setTimeout(() => {
scrollToBottom('smooth');
isInitialLoadDone.current = true;
}, 300);
return () => clearTimeout(timer);
}
}, [gen_list.length, scrollToBottom]);
useEffect(() => {
const newOptions = mediaType === 'image' ? enginesele.image : enginesele.video;
if (newOptions && newOptions.length > 0) {
// 如果当前 countType 不在新选项中,重置为第一个选项
const isValidOption = newOptions.some((item: any) => item.id === countType);
if (!isValidOption) {
setCountType(newOptions[0].id);
}
}
}, [mediaType, enginesele]);
// 点击外部关闭弹窗
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 关闭媒体类型选择弹窗
if (showMediaTypeModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowMediaTypeModal(false);
}
// 关闭引擎选择弹窗
if (showEngineModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowEngineModal(false);
}
// 关闭图片设置弹窗
if (showImageSettingsModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowImageSettingsModal(false);
}
// 关闭视频设置弹窗
if (showVideoSettingsModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowVideoSettingsModal(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showMediaTypeModal, showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
useEffect(() => {
if (mediaType !== 'video') return;
const engine = enginesele?.video?.find((e: any) => e.id === countType);
if (!engine) return;
const supportsFLF = engine.supportsFirstLastFrame ?? false;
const supportsUR = engine.supportsUniversalReference ?? true;
setReferenceMode((prevMode) => {
if (supportsUR) return 'universal';
if (prevMode === 'universal' && !supportsUR) {
if (supportsFLF) return 'first_last_frame';
}
return prevMode;
});
}, [countType, mediaType, enginesele]);
// 初始化获取参数 - 只在组件挂载时执行一次
useEffect(() => {
getEngine()
.then((data: any) => {
// console.log('引擎', data);
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 (!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 (data.engine.image && data.engine.image.length > 0) {
const savedImageEngine = data.engine.image.find((e: any) => e.id === countType);
const targetImageEngine = savedImageEngine || data.engine.image[0];
const supportedSizes = targetImageEngine.supportedSizes || {};
const resolutionLevels = Object.keys(supportedSizes).sort((a, b) => {
const levelOrder = { '1K': 0, '2K': 1, '4K': 2 };
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
});
const primaryResolution = resolutionLevels[0] || '2K';
const supportedResolutions = Object.keys(supportedSizes[primaryResolution] || {});
const newRatioOptions = supportedResolutions.map((res: any) => ({
value: res,
label: res,
}));
setRatioOptions(newRatioOptions);
setCurrentEngineSupportedSizes(supportedSizes);
setResolutionOptions(resolutionLevels.map((level) => {
const match = level.match(/(\d+)K/);
const num = match ? parseInt(match[1]) : 1;
const labels: Record<number, string> = { 1: '标清', 2: '高清', 4: '超清' };
return {
value: level,
label: `${labels[num] || '高清'} ${level}`,
};
}));
if (supportedSizes[primaryResolution] && supportedSizes[primaryResolution][supportedResolutions[0]]) {
const defaultSize = supportedSizes[primaryResolution][supportedResolutions[0]];
const [w, h] = defaultSize.split(/[×x]/);
setWidth(Number(w));
setHeight(Number(h));
setSelectedRatio(supportedResolutions[0]);
setSelectedResolution(primaryResolution);
}
if (countType === '请选择') {
setCountType(targetImageEngine.id);
}
}
})
.catch(() => {
});
calculateCredits().then((data: any) => {
// console.log('积分计算', data);
// 保存积分计算数据
setCreditCalculationData(data);
})
getgen_list(Pagebreak).then((data: any) => {
let mess_list = data.items
let total = data.total
setGen_list(mess_list)
setTotalnumber(total)
})
// getParameters()
// .then((data) => {
// let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
// let supportedResolutions = [];
// let twokwidth = [];
// let fourkwidth = [];
// for (let key in supportedSizes["2K"]) {
// supportedResolutions.push(key);
// twokwidth.push(supportedSizes["2K"][key]);
// }
// const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({
// value: res,
// label: String(index),
// }));
// 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' },
// ]);
// })
// .catch(() => { });
}, []);
// 轮询逻辑 - 当有任务在生成时,每5秒查询一次状态
useEffect(() => {
// 先清理之前的定时器
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
// 判断是否有正在生成的任务
const hasGeneratingTask = gen_list.some((item: any) => item.status === 'generating');
if (!hasGeneratingTask) return;
// 设置5秒轮询
pollingRef.current = window.setInterval(() => {
getgen_list(Pagebreak).then((data: any) => {
// 只更新正在生成的任务,不影响其他已加载的数据
setGen_list((prevList: any[]) => {
return prevList.map((prevItem: any) => {
// 只更新正在生成的任务
if (prevItem.status === 'generating') {
const newItem = data.items.find((item: any) => item.id === prevItem.id);
// 如果找到了对应的新数据,使用新数据;否则保持旧数据
return newItem || prevItem;
}
// 非生成中的任务保持不变
return prevItem;
});
});
setTotalnumber(data.total);
}).catch((error) => {
});
}, 10000);
// 清理定时器
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [gen_list, Pagebreak]);
// 点击空白处关闭图片设置浮层
useEffect(() => {
if (!showImageSettingsModal) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".image-settings-popover") &&
!target.closest(".image-settings-trigger")
) {
setShowImageSettingsModal(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [showImageSettingsModal]);
useEffect(() => {
if (!showVideoSettingsModal) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".image-settings-popover") &&
!target.closest(".image-settings-trigger")
) {
setShowVideoSettingsModal(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [showVideoSettingsModal]);
// 根据比例和分辨率计算尺寸
const calculateSizeFromRatione = (ratio: string) => {
const resolutionLevels = Object.keys(currentEngineSupportedSizes).sort((a, b) => {
const levelOrder: Record<string, number> = { '1K': 0, '2K': 1, '4K': 2 };
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
});
const targetResolution = currentEngineSupportedSizes[selectedResolution] && currentEngineSupportedSizes[selectedResolution][ratio]
? selectedResolution
: resolutionLevels.find(level => currentEngineSupportedSizes[level] && currentEngineSupportedSizes[level][ratio]) || resolutionLevels[0];
if (targetResolution && currentEngineSupportedSizes[targetResolution] && currentEngineSupportedSizes[targetResolution][ratio]) {
const size = currentEngineSupportedSizes[targetResolution][ratio];
const [w, h] = size.split(/[×x]/);
setWidth(Number(w));
setHeight(Number(h));
setSelectedRatio(ratio);
setSelectedResolution(targetResolution);
}
};
const calculateSizeFromRatiotwo = (resolution: string) => {
if (!currentEngineSupportedSizes[resolution]) {
const resolutionLevels = Object.keys(currentEngineSupportedSizes).sort((a, b) => {
const levelOrder: Record<string, number> = { '1K': 0, '2K': 1, '4K': 2 };
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
});
resolution = resolutionLevels[0] || resolution;
}
const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : [];
const newRatioOptions = supportedRatios.map((res: any) => ({
value: res,
label: res,
}));
setRatioOptions(newRatioOptions);
const targetRatio = supportedRatios.includes(selectedRatio)
? selectedRatio
: supportedRatios[0] || '';
if (targetRatio && currentEngineSupportedSizes[resolution] && currentEngineSupportedSizes[resolution][targetRatio]) {
const size = currentEngineSupportedSizes[resolution][targetRatio];
const [w, h] = size.split(/[×x]/);
setWidth(Number(w));
setHeight(Number(h));
setSelectedRatio(targetRatio);
setSelectedResolution(resolution);
}
};
// 交换宽高
const handleSwap = () => {
setWidth(height);
setHeight(width);
};
const currentVideoEngine = enginesele?.video?.find((e: any) => e.id === countType);
const supportsFirstLastFrame = currentVideoEngine?.supportsFirstLastFrame ?? false;
const supportsUniversalReference = currentVideoEngine?.supportsUniversalReference ?? true;
const handleReferenceModeChange = (mode: 'universal' | 'first_last_frame') => {
if (mode === 'first_last_frame' && !supportsFirstLastFrame) return;
if (mode === 'universal' && !supportsUniversalReference) return;
setReferenceMode(mode);
setReferenceModeDropdownVisible(false);
setCurrentMedia([]);
setInputValue('');
};
const handleSwapFrames = () => {
const temp = firstFrame;
setFirstFrame(lastFrame);
setLastFrame(temp);
};
const handleRemoveFirstFrame = () => {
setFirstFrame(null);
setLastFrame(null);
};
const handleRemoveLastFrame = () => {
setLastFrame(null);
};
// ==================== 事处理函数 ====================
const handleNewChat = () => {
// 生成唯一ID(使用时间戳)
const newId = Date.now().toString();
const newConversation: Conversation = {
id: newId,
title: '新对话',
lastMessage: '',
timestamp: new Date().toLocaleString('zh-CN'),
messages: [],
};
// 将新对话插入到列表顶部
setConversations((prev) => [newConversation, ...prev]);
// 切换到新对话
setCurrentConversationId(newId);
// 清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
// 显示提示消息
antdMessage.info('已开启新对话');
};
const handleDeleteChat = (conversationId: string) => {
// 过滤掉要删除的对话
setConversations((prev) => prev.filter((c) => c.id !== conversationId));
// 如果删除的是当前选中的对话,切换到其他对话
if (currentConversationId === conversationId) {
setCurrentConversationId(
conversations[0]?.id === conversationId
? conversations[1]?.id || null
: conversations[0]?.id || null
);
}
// 显示成功提示
antdMessage.success('对话已删除');
};
const handleSelectChat = (conversationId: string) => {
setCurrentConversationId(conversationId);
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
};
const handleSend = async () => {
const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame';
if (isFirstLastFrameMode) {
if (!inputValue.trim()) {
antdMessage.warning('请输入内容');
return;
}
} else {
if (!inputValue.trim() && currentMedia.length === 0) {
antdMessage.warning('请输入内容或上传图片/视频');
return;
}
}
let mediaReferences: MediaReference[] | undefined;
if (isFirstLastFrameMode) {
mediaReferences = [];
if (firstFrame) {
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
}
if (lastFrame) {
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
}
if (mediaReferences.length === 0) {
mediaReferences = undefined;
}
} else {
if (currentMedia.length > 0) {
mediaReferences = currentMedia.map((m) => {
if (m.type === 'image') {
const { duration, ...rest } = m;
return rest;
}
return m;
});
} else {
mediaReferences = undefined;
}
}
// console.log(mediaReferences);
// 创建用户消息对象
const newMessage: Message = {
id: '',
gen_type: mediaType,
original_prompt: inputValue.trim(),
engine_id: countType,
idempotency_key: new Date().toLocaleString('zh-CN'),
media_references: mediaReferences,
// 图片参数(仅图片模式时添加)
...(mediaType === 'image' && {
image_size: selectedResolution,
image_proportion: selectedRatio,
image_px: width + "x" + height,
}),
// 视频参数(仅视频模式时添加)
...(mediaType === 'video' && {
duration: videoDuration,
aspect_ratio: videoAspectRatio,
resolution: videoResolution,
}),
};
// 设置加载状态
setLoading(true);
createGenerationTask(newMessage).then(() => {
// 创建任务成功后,清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
// 创建任务成功后,重置页数为1,获取最新列表
const newPagebreak = { ...Pagebreak, page: 1 };
setPagebreak(newPagebreak);
getgen_list(newPagebreak).then((data: any) => {
// 将data.items的最后一个元素添加到gen_list末尾
setGen_list((prev: any[]) => [...prev, data.items[data.items.length - 1]]);
setTotalnumber(data.total);
// 发送消息后滚动到底部
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}).catch((error) => {
});
}).catch((error: any) => {
// 失败时不清空输入框和媒体
// 尝试从错误对象中提取detail字段
let errorMessage = "创建任务失败";
if (error?.response?.data?.detail) {
errorMessage = error.response.data.detail;
} else if (error?.data?.detail) {
errorMessage = error.data.detail;
} else if (error?.detail) {
errorMessage = error.detail;
} else if (error?.message) {
errorMessage = error.message;
}
antdMessage.error({
content: errorMessage,
duration: 3,
});
}).finally(() => {
setLoading(false);
});
setTimeout(() => {
setLoading(false);
}, 3000);
};
// 加载更多
const handleLoadMore = async () => {
// 检查是否还有更多数据
if (gen_list.length >= Totalnumber) {
return;
}
// 保存当前滚动位置(相对于顶部的偏移)
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const scrollTopBefore = scrollContainer.scrollTop;
const scrollHeightBefore = scrollContainer.scrollHeight;
setIsLoadingMore(true);
try {
const nextPage = Pagebreak.page + 1;
const result = await getgen_list({
...Pagebreak,
page: nextPage,
});
// 处理返回数据,兼容数组或对象格式
const data = Array.isArray(result) ? { items: result, total: result.length } : result;
// 更新总数
if (data.total) {
setTotalnumber(data.total);
}
// 更新页码
setPagebreak((prev: any) => ({ ...prev, page: nextPage }));
// 去重后合并数据(根据 id 去重,新数据添加到前面)
setGen_list((prev: any[]) => {
const existingIds = new Set(prev.map((item: any) => item.id));
// 只添加不存在的新数据,保持新数据的原有顺序
const newItems = data.items.filter((item: any) => {
if (!item.id) return false;
if (existingIds.has(item.id)) return false;
existingIds.add(item.id);
return true;
});
// 新数据在前,旧数据在后
return [...newItems, ...prev];
});
// 恢复滚动位置(等待DOM更新完成后执行,增加超时时间确保DOM完全更新)
setTimeout(() => {
const scrollHeightAfter = scrollContainer.scrollHeight;
const addedHeight = scrollHeightAfter - scrollHeightBefore;
// 新数据添加到前面,保持当前查看内容的位置不变
scrollContainer.scrollTop = scrollTopBefore + addedHeight;
}, 200);
} catch (error) {
} finally {
setIsLoadingMore(false);
}
};
const getVideoDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
window.URL.revokeObjectURL(video.src);
resolve(video.duration);
};
video.onerror = () => {
window.URL.revokeObjectURL(video.src);
reject(new Error('无法获取视频时长'));
};
video.src = URL.createObjectURL(file);
});
};
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();
img.onload = () => {
window.URL.revokeObjectURL(img.src);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
window.URL.revokeObjectURL(img.src);
reject(new Error('无法读取图片尺寸'));
};
img.src = URL.createObjectURL(file);
});
};
const getVideoDimensions = (file: File): Promise<{ width: number; height: number; duration: number }> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
window.URL.revokeObjectURL(video.src);
resolve({ width: video.videoWidth, height: video.videoHeight, duration: video.duration });
};
video.onerror = () => {
window.URL.revokeObjectURL(video.src);
reject(new Error('无法读取视频尺寸'));
};
video.src = URL.createObjectURL(file);
});
};
// 图片尺寸校验:宽高比 (0.4, 2.5),宽高长度 (300, 6000)
const validateImageDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
return null;
};
// 视频尺寸校验:宽高比 [0.4, 2.5],宽高 [300, 6000],总像素 [409600, 8295044]
const validateVideoDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
const totalPixels = width * height;
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
return null;
};
const handleUpload = async (file: File, frameTarget?: 'first' | 'last' | File[]) => {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
if (!isImage) {
antdMessage.error('首尾帧模式仅支持上传图片');
return false;
}
if (file.size / 1024 / 1024 > 10) {
antdMessage.error('图片大小不能超过10MB');
return false;
}
try {
const { width, height } = await getImageDimensions(file);
const error = validateImageDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
}
} catch {
antdMessage.error('无法读取图片尺寸,请检查文件是否损坏');
return false;
}
const effectiveUploadTarget = frameTarget || uploadTarget;
if (!effectiveUploadTarget) {
antdMessage.error('请选择首帧或尾帧上传位置');
return false;
}
setUploading(true);
try {
const res = await uploadImage(file);
const mediaRef: MediaReference = {
name: file.name,
type: 'image',
url: res.url,
role: effectiveUploadTarget === 'first' ? 'first_frame' : 'last_frame',
};
if (effectiveUploadTarget === 'first') {
setFirstFrame(mediaRef);
} else {
setLastFrame(mediaRef);
}
antdMessage.success('图片上传成功');
} catch (error) {
antdMessage.error('上传失败');
} finally {
setUploading(false);
setUploadTarget(null);
}
return false;
}
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
antdMessage.error('仅支持图片、视频或音频文件');
return false;
}
if (isAudio) {
const audioExt = file.name.split('.').pop()?.toLowerCase();
if (!['wav', 'mp3'].includes(audioExt || '')) {
antdMessage.error('音频仅支持wav和mp3格式');
return false;
}
}
if (mediaType === 'image' && (isVideo || isAudio)) {
antdMessage.error('图片模式仅支持上传图片');
return false;
}
if (isAudio && mediaType !== 'video') {
antdMessage.error('仅视频模式支持上传音频');
return false;
}
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
if (file.size / 1024 / 1024 > maxMB) {
antdMessage.error(`${fileTypeText}大小不能超过${maxMB}MB`);
return false;
}
// 图片尺寸校验(所有图片都需要校验)
if (isImage) {
try {
const { width, height } = await getImageDimensions(file);
const error = validateImageDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
}
} catch {
antdMessage.error('无法读取图片尺寸,请检查文件是否损坏');
return false;
}
}
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
if (isImage && imageCount >= maxImage) {
antdMessage.error(`该引擎最多上传${maxImage}张图片`);
return false;
}
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
if (isVideo && videoCount >= maxVideo) {
antdMessage.error(`该引擎最多上传${maxVideo}个视频`);
return false;
}
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
if (isAudio && audioCount >= maxAudio) {
antdMessage.error(`该引擎最多上传${maxAudio}个音频`);
return false;
}
let videoDuration = 0;
let audioDuration = 0;
if (isVideo) {
try {
videoDuration = await getVideoDuration(file);
if (videoDuration < 2) {
antdMessage.error('视频素材最短不能少于 2 秒');
return false;
}
const existingVideoDuration = currentMedia
.filter((m) => m.type === 'video')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingVideoDuration + videoDuration > 15) {
antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
return false;
}
// 视频尺寸校验
const { width, height } = await getVideoDimensions(file);
const error = validateVideoDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
}
} catch {
antdMessage.error('无法获取视频信息,请检查文件是否损坏');
return false;
}
}
if (isAudio) {
try {
audioDuration = await getAudioDuration(file);
if (audioDuration < 2) {
antdMessage.error('音频素材最短不能少于 2 秒');
return false;
}
const existingAudioDuration = currentMedia
.filter((m) => m.type === 'audio')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingAudioDuration + audioDuration > 15) {
antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
return false;
}
} catch {
antdMessage.error('无法获取音频信息,请检查文件是否损坏');
return false;
}
}
setUploading(true);
try {
let res;
if (isImage) {
res = await uploadImage(file);
} else if (isAudio) {
res = await uploadAudio(file, audioDuration);
} else {
res = await uploadVideo(file, videoDuration);
}
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const newList = [...currentMedia, {
name: file.name,
type: mediaType,
url: res.url,
label: '',
...(isVideo && { duration: videoDuration }),
...(isAudio && { duration: audioDuration }),
}];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
} catch (error) {
antdMessage.error('上传失败');
} finally {
setUploading(false);
}
return false;
};
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }> => {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
antdMessage.error('仅支持图片、视频或音频文件');
return false;
}
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
if (file.size / 1024 / 1024 > maxMB) {
antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
return false;
}
if (isAudio) {
const audioExt = file.name.split('.').pop()?.toLowerCase();
if (!['wav', 'mp3'].includes(audioExt || '')) {
antdMessage.error('音频仅支持wav和mp3格式');
return false;
}
}
// 图片尺寸校验
if (isImage) {
try {
const { width, height } = await getImageDimensions(file);
const error = validateImageDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
}
} catch {
antdMessage.error('无法读取图片尺寸,请检查文件是否损坏');
return false;
}
}
let videoDuration = 0;
let audioDuration = 0;
if (isVideo) {
try {
videoDuration = await getVideoDuration(file);
if (videoDuration < 2) {
antdMessage.error('视频素材最短不能少于 2 秒');
return false;
}
const latestMedia = useAppStore.getState().currentMedia;
const existingVideoDuration = latestMedia
.filter((m) => m.type === 'video')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingVideoDuration + videoDuration > 15) {
antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
return false;
}
} catch {
antdMessage.error('无法获取视频信息,请检查文件是否损坏');
return false;
}
}
if (isAudio) {
try {
audioDuration = await getAudioDuration(file);
if (audioDuration < 2) {
antdMessage.error('音频素材最短不能少于 2 秒');
return false;
}
const latestMedia = useAppStore.getState().currentMedia;
const existingAudioDuration = latestMedia
.filter((m) => m.type === 'audio')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingAudioDuration + audioDuration > 15) {
antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
return false;
}
} catch {
antdMessage.error('无法获取音频信息,请检查文件是否损坏');
return false;
}
}
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const pendingMedia: MediaReference = {
name: file.name,
type: mediaType,
url: '',
label: '',
...(isVideo && { duration: videoDuration }),
...(isAudio && { duration: audioDuration }),
};
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
if (!validateMediaReferencesBeforeAdd(latestMedia, [pendingMedia])) {
return false;
}
try {
let res;
if (isImage) {
res = await uploadImage(file);
} else if (isAudio) {
res = await uploadAudio(file, audioDuration);
} else {
res = await uploadVideo(file, videoDuration);
}
return {
name: pendingMedia.name,
type: pendingMedia.type,
url: res.url,
label: pendingMedia.label || '',
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
};
} catch (error) {
antdMessage.error('上传失败');
return false;
}
};
const handleBatchUpload = async (files: File[]) => {
let successCount = 0;
let failCount = 0;
setUploading(true);
for (const file of files) {
const result = await doUpload(file);
if (result) {
const latestMedia = useAppStore.getState().currentMedia;
const newList = [...latestMedia, result];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
successCount++;
} else {
failCount++;
}
}
setUploading(false);
if (successCount > 0) {
antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `${failCount}个文件上传失败` : ''}`);
}
};
const getMediaDurationTotal = (items: MediaReference[], type: 'video' | 'audio') => {
return items
.filter((m) => m.type === type)
.reduce((sum, m) => sum + (Number(m.duration) || 0), 0);
};
const validateMediaReferencesBeforeAdd = (baseMedia: MediaReference[], incoming: MediaReference[]) => {
if (!incoming.length) return false;
if (mediaType === 'image' && incoming.some((item) => item.type !== 'image')) {
antdMessage.error('图片模式仅支持添加图片素材');
return false;
}
if (incoming.some((item) => item.type === 'audio') && mediaType !== 'video') {
antdMessage.error('仅视频模式支持添加音频素材');
return false;
}
const imageCount = baseMedia.filter((m) => m.type === 'image').length;
const videoCount = baseMedia.filter((m) => m.type === 'video').length;
const audioCount = baseMedia.filter((m) => m.type === 'audio').length;
const incomingImageCount = incoming.filter((m) => m.type === 'image').length;
const incomingVideoCount = incoming.filter((m) => m.type === 'video').length;
const incomingAudioCount = incoming.filter((m) => m.type === 'audio').length;
if (imageCount + incomingImageCount > maxImage) {
antdMessage.error(`该引擎最多上传${maxImage}张图片,当前还能添加 ${Math.max(0, maxImage - imageCount)} 张`);
return false;
}
if (videoCount + incomingVideoCount > maxVideo) {
antdMessage.error(`该引擎最多上传${maxVideo}个视频,当前还能添加 ${Math.max(0, maxVideo - videoCount)} 个`);
return false;
}
if (audioCount + incomingAudioCount > maxAudio) {
antdMessage.error(`该引擎最多上传${maxAudio}个音频,当前还能添加 ${Math.max(0, maxAudio - audioCount)} 个`);
return false;
}
for (const item of incoming) {
if (item.type !== 'video') continue;
const duration = Number(item.duration);
if (!Number.isFinite(duration) || duration <= 0) {
antdMessage.error(`${item.name || '视频素材'}缺少视频秒数,不能用于 AI 创作`);
return false;
}
if (duration < 2) {
antdMessage.error(`${item.name || '视频素材'}最短不能少于 2 秒`);
return false;
}
if (duration > 15) {
antdMessage.error(`${item.name || '视频素材'}最长不能超过 15 秒`);
return false;
}
}
const totalVideoDuration = getMediaDurationTotal(baseMedia, 'video') + getMediaDurationTotal(incoming, 'video');
if (totalVideoDuration > 15) {
antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${totalVideoDuration.toFixed(1)} 秒`);
return false;
}
const totalAudioDuration = getMediaDurationTotal(baseMedia, 'audio') + getMediaDurationTotal(incoming, 'audio');
if (totalAudioDuration > 15) {
antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${totalAudioDuration.toFixed(1)} 秒`);
return false;
}
return true;
};
const normalizeUploadResourceHistoryItem = (item: UploadResourceHistoryItem): MediaReference | null => {
const media = item.mediaReference;
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
if (!url) {
antdMessage.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
return null;
}
if (refType === 'audio' && mediaType !== 'video') {
antdMessage.error('仅视频模式支持添加音频素材');
return null;
}
const duration = Number(media?.duration ?? item.durationSeconds);
return {
name: media?.name || item.fileName || item.id,
type: refType,
url,
source: 'upload_resource',
upload_resource_id: item.id,
label: '',
...((refType === 'video' || refType === 'audio') && Number.isFinite(duration) && duration > 0 ? { duration } : {}),
};
};
const handleUploadResourceHistorySelected = (items: UploadResourceHistoryItem[]) => {
const normalized = items
.map(normalizeUploadResourceHistoryItem)
.filter(Boolean) as MediaReference[];
if (!normalized.length) return;
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
return;
}
const newList = [...latestMedia, ...normalized];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
antdMessage.success(`已添加 ${normalized.length} 个历史上传素材参考`);
};
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
if (asset.assetType === 'Audio') {
antdMessage.error('音频私域素材暂不支持用于 AI 创作');
return null;
}
const refType: 'image' | 'video' = asset.assetType === 'Video' ? 'video' : 'image';
const fallbackName = privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟素材' : '真人素材';
const previewUrl = refType === 'video'
? (asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.providerUrl || '')
: (asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '');
return {
name: asset.name || fallbackName,
type: refType,
url: previewUrl,
source: 'private_portrait_asset',
private_asset_id: asset.id,
label: '',
...(refType === 'video' && { duration: Number(asset.videoDuration) || undefined }),
};
};
const openPrivatePortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
setPrivateAssetPickerLibraryType(libraryType);
setPrivateAssetPickerOpen(true);
};
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
const normalized = assets
.map(normalizePrivatePortraitAsset)
.filter(Boolean) as MediaReference[];
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
return;
}
const newList = [...latestMedia, ...normalized];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
antdMessage.success(`已添加 ${normalized.length}${privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材参考`);
};
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
return url;
}
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const handleRemoveMedia = (index: number) => {
const newList = currentMedia.filter((_, i) => i !== index);
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
};
// const handleKeyPress = (e: React.KeyboardEvent) => {
// if (e.key === 'Enter' && !e.shiftKey) {
// e.preventDefault();
// handleSend();
// }
// };
// 检测光标前的 @ 符号
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
const cursorPos = textarea.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const atMatch = textBeforeCursor.match(/@([^@\s]*)$/);
if (atMatch && currentMedia.length > 0) {
setMentionVisible(true);
return true;
}
setMentionVisible(false);
return false;
};
// 输入框变化处理
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setInputValue(value);
const textarea = e.target;
checkMention(textarea, value);
};
// 键盘事件处理(ESC 关闭提及、Tab/Enter 选择)
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionVisible) {
if (e.key === 'Escape') {
setMentionVisible(false);
}
}
};
// 插入 @ 提及
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();
textarea.setSelectionRange(pos, pos);
}, 0);
};
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 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';
};
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);
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);
};
// ==================== 渲染 ====================
const isFirstLastFrameComposer = mediaType === 'video' && referenceMode === 'first_last_frame';
const composerCanSend = !uploading && (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 的动作。';
const composerHelperText = mediaType === 'image'
? '图片参考 · 适合海报、产品图、场景图与风格图生成'
: isFirstLastFrameComposer
? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡'
: '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材';
const maxImage = maxImageCount;
const maxVideo = maxVideoCount;
const maxAudio = currentEngine?.maxAudioCount ?? 1;
return (
<Layout className="ai-create-page" style={{
margin: '-24px -32px -32px',
borderRadius: 22,
height: 'calc(100vh - 34px)',
background: '#fff',
overflow: 'hidden',
}}>
{/* 隐藏的音频播放器 */}
<audio
id="audio-player"
src={playingAudioUrl || null}
autoPlay
onEnded={() => setPlayingAudioUrl(null)}
style={{ display: 'none' }}
/>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={220}
style={{
background: 'rgba(255,255,255,0.7)',
backdropFilter: 'blur(16px)',
borderRight: '1px solid rgba(139, 92, 246, 0.08)',
}}
>
<div style={{ padding: 12 }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ marginBottom: 12, width: '100%' }}
/>
{!collapsed && (
<Button
block
type="primary"
icon={<PlusOutlined />}
onClick={handleNewChat}
style={{ marginBottom: 16, borderRadius: 8 }}
>
新对话
</Button>
)}
{!collapsed && conversations.length > 0 && (
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
{conversations.map((conversation) => (
<div
key={conversation.id}
onClick={() => handleSelectChat(conversation.id)}
style={{
display: 'flex',
alignItems: 'center',
padding: '10px 12px',
marginBottom: 4,
borderRadius: 10,
cursor: 'pointer',
background: currentConversationId === conversation.id ? '#FAFBFC' : 'transparent',
border: currentConversationId === conversation.id ? '1px solid #ede9fe' : 'none',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = currentConversationId === conversation.id ? '#FAFBFC' : '#FFFFFF';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = currentConversationId === conversation.id ? '#FAFBFC' : 'transparent';
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#2f3440', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conversation.title}
</p>
<p style={{ margin: 2, fontSize: 11, color: '#98a2b3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conversation.lastMessage || '暂无消息'}
</p>
</div>
<Popconfirm
title="确定删除此对话?"
onConfirm={() => handleDeleteChat(conversation.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
icon={<DeleteOutlined />}
style={{ color: '#98a2b3', padding: 4 }}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</div>
))}
</div>
)}
</div>
</Sider>
)}
{/* 主内容区 */}
<Layout style={{ display: 'flex', flex: 1, background: '#fff' }}>
{/* 头部 - 显示对话标题和模型信息 */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 24px', borderRadius: 22,
background: 'rgb(255, 255, 255)',
backdropFilter: 'blur(22px)',
border: '1px solid rgba(231, 234, 240, 0.82)',
// boxShadow: '0 16px 44px rgba(31, 41, 55, 0.06)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: '15%', height: 1, background: 'linear-gradient(90deg, transparent, rgba(117,106,136,0.18), rgba(232,227,236,0.48), transparent)', borderRadius: 1 }} />
<div style={{ flex: 1, minWidth: 0, textAlign: 'center' }}>
<h2 style={{
margin: 0,
fontSize: 18,
fontWeight: 700,
color: '#8b5cf6',
letterSpacing: 0.4,
}}>
AI创作
</h2>
<p style={{ fontSize: 13, color: '#667085', margin: '4px 0 0 0' }}>
输入想法、剧本或上传参考,智能生成视频/图片
</p>
</div>
<div style={{ width: '15%', height: 1, background: 'linear-gradient(90deg, transparent, rgba(232,227,236,0.48), rgba(117,106,136,0.18), transparent)', borderRadius: 1 }} />
</div>
</div>
{/* 消息区域 */}
<Content
style={{
flex: 1,
margin: 0,
padding: '20px 24px 22px',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
}}
>
{/* 空状态 - 没有对话或当前对话没有消息时显示 */}
{gen_list.length === 0 && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
width: 80,
height: 80,
borderRadius: 50,
background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
}}
>
<RobotOutlined style={{ fontSize: 40, color: '#fff' }} />
</div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 500, color: '#2f3440' }}>
你好,想创作什么?
</h2>
<p style={{ margin: 8, fontSize: 13, color: '#98a2b3' }}>
输入想法、剧本或上传参考,开始你的创作之旅
</p>
</div>
)}
{/* 消息列表 - gen_list 有数据时显示 */}
{gen_list.length > 0 && (
<div
ref={scrollContainerRef}
className="ai-create-scroll"
style={{
flex: 1,
overflowY: 'auto',
paddingRight: 10,
paddingBottom: 18,
// background: 'linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0))',
background: '#fff',
// borderRadius: 22,
}}
onScroll={(e) => {
const target = e.currentTarget;
const now = Date.now();
const isAutoScroll = now - lastAutoScrollTime.current < 500;
if (!isAutoScroll && isInitialLoadDone.current && target.scrollTop <= 50 && !isLoadingMore && gen_list.length < Totalnumber) {
handleLoadMore();
}
}}
>
{/* 加载更多提示 */}
{isLoadingMore && (
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
<span style={{ fontSize: 12, color: '#8b5cf6' }}>加载中...</span>
</div>
)}
{/* 遍历消息列表 */}
{(() => {
const msgApi = message;
return gen_list.map((msg) => (
<div
key={msg.id}
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start', // 所有消息左对齐
marginBottom: 16,
}}
>
<div style={{ width: '100%', display: 'flex', gap: 10 }}>
{/* 头像 */}
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
// boxShadow: '0 8px 18px rgba(47, 52, 64, 0.15)' ,
}}
>
<RobotOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
{/* 消息内容 */}
<div style={{ flex: 1 }}>
{/* 时间戳和参数信息 */}
{/* 消息气泡 */}
<div
style={{
background: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(18px)',
borderRadius: '22px',
padding: '14px 16px',
width: '70%',
minWidth: 500,
boxSizing: 'border-box',
boxShadow: '0 18px 50px rgba(47, 52, 64, 0.075)',
position: 'relative',
border: '1px solid rgba(231, 234, 240, 0.9)',
}}
>
<div style={{ margin: 4, fontSize: 11, color: '#98a2b3', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
<span style={{
background: msg.genType === 'image' ? 'rgba(139, 92, 246, 0.10)' : 'rgba(139, 92, 246, 0.10)',
color: msg.genType === 'image' ? '#8b5cf6' : '#8b5cf6',
padding: '2px 8px',
borderRadius: 12,
fontWeight: 500,
fontSize: 11,
marginLeft: 8
}}>
{msg.genType === 'image' ? '图片生成' : '视频生成'}
</span>
</div>
{/* 附件详情 - 右上角 */}
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#8b5cf6', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(139, 92, 246, 0.2)', background: 'rgba(139, 92, 246, 0.04)' }} onClick={(e) => { 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); }}>附件详情</span>
</div>
)}
{/* 操作按钮 - 右下角 */}
<div style={{ position: 'absolute', bottom: 8, right: 8, zIndex: 100, display: 'flex', gap: 6 }}>
<button
onClick={(e) => {
e.stopPropagation();
setInputValue(msg.originalPrompt || '');
if (msg.genType) {
setMediaType(msg.genType);
}
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
if (hasFirstLastFrame && msg.genType === 'video') {
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
setFirstFrame(first ? { ...first, label: first.label || '', duration: first.duration } : null);
setLastFrame(last ? { ...last, label: last.label || '', duration: last.duration } : null);
setCurrentMedia([]);
setReferenceMode('first_last_frame');
} else {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
name: ref.name,
type: ref.type,
url: ref.url,
label: ref.label || '',
role: ref.role,
duration: ref.duration,
})));
setFirstFrame(null);
setLastFrame(null);
setReferenceMode('universal');
}
} else {
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
}
msgApi.success('已加载到编辑区');
}}
style={{
height: 28,
padding: '0 10px',
borderRadius: 10,
border: 'none',
background: 'rgba(139, 92, 246, 0.08)',
color: '#8b5cf6',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
gap: 4,
fontSize: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.08)';
}}
>
<ReloadOutlined style={{ fontSize: 12 }} />
重新编辑
</button>
<Popconfirm
title="确定要删除吗?"
onConfirm={async () => {
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="取消"
>
<button
onClick={(e) => e.stopPropagation()}
style={{
height: 28,
padding: '0 10px',
borderRadius: 10,
border: 'none',
background: 'rgba(139, 92, 246, 0.08)',
color: '#8b5cf6',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
gap: 4,
fontSize: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(164, 91, 91, 0.1)';
e.currentTarget.style.color = '#A45B5B';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.08)';
e.currentTarget.style.color = '#8b5cf6';
}}
>
<DeleteOutlined style={{ fontSize: 12 }} />
删除
</button>
</Popconfirm>
</div>
{/* 文本内容 */}
{/* <div
style={{
width: '100%',
position: 'relative',
margin: '8px 0',
padding: '12px 16px',
backgroundColor: 'rgba(139, 92, 246, 0.04)',
borderRadius: 10,
border: '1px solid rgba(139, 92, 246, 0.08)',
fontSize: 13,
color: '#475467',
lineHeight: 1.6,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(47, 52, 64, 0.035)',
}}
onMouseEnter={() => {
setExpandedPrompts(prev => {
const newSet = new Set(prev);
newSet.add(msg.id);
return newSet;
});
}}
onMouseLeave={() => {
setExpandedPrompts(prev => {
const newSet = new Set(prev);
newSet.delete(msg.id);
return newSet;
});
}}
>
默认显示:一行省略
<div style={{
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: expandedPrompts.has(msg.id) ? 'none' : 'block',
}}>
{msg.originalPrompt}
</div>
鼠标移入显示:完整内容
<div style={{
width: '100%',
maxHeight: 200,
overflowY: 'auto',
display: expandedPrompts.has(msg.id) ? 'block' : 'none',
wordBreak: 'break-word',
}}>
{msg.originalPrompt}
</div>
</div> */}
{/* 根据 status 显示不同内容 */}
{(msg.status === 'generating' || msg.status === 'failed' || msg.status === 'completed') && (
<div style={{
display: 'flex', gap: 16, marginBottom: 16, marginTop: 16, width: '100%', alignItems: 'flex-start',
}}>
<div style={{ width: '50%', overflow: 'hidden', borderRadius: 12, position: 'relative', height: 220, border: '1px solid #E7EAF0', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)' }}>
{msg.status === 'generating' ? (
<>
<div style={{ position: 'absolute', top: 12, left: 12, display: 'flex', alignItems: 'center', gap: 8, zIndex: 2 }}>
{/* <div style={{ width: 20, height: 20, border: '2px solid #ddd6fe', borderTopColor: '#8b5cf6', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> */}
</div>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.6) 50%, transparent 100%)', animation: 'shimmer 2s infinite' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(139, 92, 246, 0.2)' }}>
<div style={{ width: 48, height: 48, border: '3px solid #ddd6fe', borderTopColor: '#8b5cf6', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</div>
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>生成中...</span>
{/* <div style={{ display: 'flex', gap: 4 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#a8a1b6', animation: 'pulse 1.5s ease-in-out 0.2s infinite' }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#ddd6fe', animation: 'pulse 1.5s ease-in-out 0.4s infinite' }} />
</div> */}
</div>
</>
) : msg.status === 'failed' ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'rgba(168, 90, 106, 0.10)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<WarningOutlined style={{ color: '#A45B5B', fontSize: 22 }} />
</div>
<span style={{ fontSize: 14, color: '#A45B5B', fontWeight: 620 }}>生成失败(积分已退)</span>
{msg.errorMessage && (
<span style={{ fontSize: 13, color: '#A45B5B', textAlign: 'center', padding: '0 8px', lineHeight: 1.5 }}>{msg.errorMessage}</span>
)}
</div>
) : (
<>
{msg.genType === 'image' ? (
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.imageUrl}&w=300&p=50`} alt={msg.name} style={{ width: '100%', height: '100%', borderRadius: 12, objectFit: 'contain', cursor: 'pointer', transition: 'transform 0.3s ease' }} onClick={() => { setPreviewUrl(msg.imageUrl); setPreviewType('image'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} />
) : (
<>
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.videoCoverUrl}&w=300&p=50`} alt={msg.name} style={{ width: '100%', height: '100%', borderRadius: 12, objectFit: 'contain', cursor: 'pointer', transition: 'transform 0.3s ease' }} onClick={() => { setPreviewUrl(msg.videoUrl); setPreviewType('video'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: 56, height: 56, background: 'rgba(47, 52, 64, 0.72)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none', boxShadow: '0 10px 24px rgba(47, 52, 64, 0.22)' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z" /></svg>
</div>
</>
)}
</>
)}
</div>
<div style={{ width: '50%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ background: '#FFFFFF', borderRadius: 12, padding: 12, border: '1px solid #E7EAF0', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p style={{ width: '100%', height: 150, overflow: 'auto', padding: 0, marginTop: 0, marginBottom: 0, lineHeight: 2, color: '#475467', fontSize: 13 }}>{msg.originalPrompt}</p>
</div>
<div style={{ fontSize: 12, color: '#667085', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{
// background: 'rgba(139, 92, 246, 0.08)',
borderRadius: 16, color: '#8b5cf6', fontWeight: 500
}}>{msg.engineSnapshot.name}</span>
<span style={{
// background: 'rgba(139, 92, 246, 0.08)',
borderRadius: 16, color: '#667085'
}}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}秒 · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
<span style={{
// background: 'rgba(164, 91, 91, 0.08)',
fontSize: 14,
borderRadius: 16, color: '#8b5cf6'
}}>消耗积分:{msg.creditsCost}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
))
})()}
{/* 消息列表底部标记 - 用于自动滚动 */}
<div ref={messagesEndRef} />
</div>
)}
{/* 附件详情悬浮窗 - 参考素材 hover 块风格 */}
{attachmentPopupVisible && attachmentPopupMessageId && (() => {
const currentAttachmentMessage = gen_list.find((msg: any) => msg.id === attachmentPopupMessageId);
const attachmentRefs = currentAttachmentMessage?.mediaReferences || [];
return (
<>
{/* 遮罩层 - 点击关闭悬浮窗 */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'transparent',
zIndex: 9998,
}}
onClick={() => {
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}}
/>
<div
className="ai-reference-expanded-tray ai-attachment-popover"
style={{
position: 'fixed',
top: `max(84px, ${attachmentPopupPosition.y - 96}px)`,
left: `max(24px, min(${attachmentPopupPosition.x - 250}px, calc(100vw - 456px)))`,
width: 424,
padding: '12px 12px 10px',
borderRadius: 18,
background: 'rgba(255,255,255,0.98)',
border: '1px solid #E7EAF0',
boxShadow: '0 24px 60px rgba(31, 41, 55, 0.12)',
backdropFilter: 'blur(18px)',
zIndex: 9999,
pointerEvents: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 13, color: '#344054', fontWeight: 800 }}>参考附件</span>
<span style={{ fontSize: 11, color: '#98A2B3', fontWeight: 600 }}>点击素材查看,或直接下载原文件</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* <span style={{ fontSize: 11, color: '#98A2B3', fontWeight: 700 }}>{attachmentRefs.length}/12</span> */}
<button
onClick={() => {
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}}
style={{
width: 26,
height: 26,
borderRadius: '50%',
border: '1px solid transparent',
background: '#F6F7FA',
cursor: 'pointer',
color: '#98a2b3',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#EEF1F6';
e.currentTarget.style.color = '#2f3440';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#F6F7FA';
e.currentTarget.style.color = '#98a2b3';
}}
>
<CloseOutlined style={{ fontSize: 12 }} />
</button>
</div>
</div>
<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' ? '图片' : refType === 'video' ? '视频' : '音频'}${idx + 1}`);
return (
<div
key={`${ref.url || ref.name || idx}-${idx}`}
style={{
flex: '0 0 92px',
width: 92,
borderRadius: 16,
background: '#FFFFFF',
border: '1px solid #E7EAF0',
boxShadow: '0 8px 18px rgba(31, 41, 55, 0.06)',
padding: 7,
boxSizing: 'border-box',
}}
>
<div
onClick={(e) => {
if (refType === 'audio') {
e.stopPropagation();
handleAudioPlay(ref.url);
} else {
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' ? (
<img
src={buildAttachmentAssetUrl(ref.url)}
alt={ref.name || label}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : refType === 'video' ? (
<>
<video
src={buildAttachmentAssetUrl(ref.url)}
muted
playsInline
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(31, 41, 55, 0.08)' }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'rgba(47, 52, 64, 0.72)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 8px 18px rgba(31, 41, 55, 0.22)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z" /></svg>
</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}
</span> */}
</div>
<div title={ref.name || label} style={{ marginTop: 6, color: '#667085', fontSize: 10, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
{ref.name || label}
</div>
<div style={{ display: 'flex', gap: 5, marginTop: 7 }}>
<button
onClick={() => {
openAttachmentPreview(ref);
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}}
style={{
flex: 1,
height: 24,
borderRadius: 9,
border: '1px solid #E7EAF0',
background: '#F8FAFC',
color: '#8b5cf6',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#EEF1F6'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#F8FAFC'; }}
>
查看
</button>
{/* <button
onClick={(e) => downloadAttachmentRef(ref, e)}
title="下载"
style={{
width: 26,
height: 24,
borderRadius: 9,
border: '1px solid #E7EAF0',
background: '#FFFFFF',
color: '#667085',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#EEF1F6'; e.currentTarget.style.color = '#2f3440'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#FFFFFF'; e.currentTarget.style.color = '#667085'; }}
>
<DownloadOutlined style={{ fontSize: 12 }} />
</button> */}
</div>
</div>
);
})}
</div>
</div>
</>
);
})()}
{/* 输入区域 - 始终显示 */}
<div
style={{
position: 'relative',
background: '#ffffff',
borderRadius: 24,
padding: '18px 72px 16px 20px',
boxShadow: '0 22px 64px rgba(31, 41, 55, 0.08), 0 1px 0 rgba(255,255,255,0.98) inset',
transition: 'all 0.25s ease',
border: '1px solid rgba(231, 234, 240, 0.78)',
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 }}>
{/* 首尾帧模式 */}
{mediaType === 'video' && referenceMode === 'first_last_frame' ? (
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'stretch', gap: 12, width: '100%', height: 100 }}>
{/* 首帧 */}
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>首帧(可选)</span>
</div>
{firstFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
alt={firstFrame.name}
onClick={() => {
setAttachmentPreviewUrl(firstFrame.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(firstFrame.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8, cursor: 'pointer', border: '1px solid rgba(139, 92, 246, 0.2)', boxShadow: '0 3px 10px rgba(47, 52, 64, 0.06)' }}
/>
<button
onClick={handleRemoveFirstFrame}
style={{ position: 'absolute', top: -6, right: -6, width: 18, height: 18, border: 'none', background: '#A45B5B', borderRadius: 50, cursor: 'pointer', color: '#fff', fontSize: 9, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 4px rgba(164, 91, 91, 0.3)', zIndex: 10 }}
>
<DeleteOutlined style={{ fontSize: 9 }} />
</button>
</div>
) : (
<UploadSelector
accept="image/*"
multiple={false}
onLocalSelect={(files) => {
if (files.length > 0) {
handleUpload(files[0], 'first');
}
}}
onHistorySelect={(items) => {
if (items.length > 0) {
const item = items[0];
const mediaRef: MediaReference = {
name: item.fileName || '',
type: 'image',
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
role: 'first_frame',
label: '',
};
setFirstFrame(mediaRef);
antdMessage.success('成功添加首帧');
}
}}
onPortraitSelect={(assets) => {
if (assets.length > 0) {
const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = {
name: asset.name || '首帧图片',
type: 'image',
url: previewUrl,
role: 'first_frame',
label: '',
};
setFirstFrame(mediaRef);
antdMessage.success('成功添加首帧');
}
}}
uploading={uploading}
>
<div
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.28)'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)'; }}
>
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传首帧</span>
</div>
</UploadSelector>
)}
</div>
{/* 调换按钮 */}
<button
onClick={handleSwapFrames}
disabled={!firstFrame || !lastFrame}
title="调换首尾帧"
style={{ width: 32, height: 32, alignSelf: 'center', borderRadius: 50, border: '1px solid rgba(231, 234, 240, 0.95)', background: firstFrame && lastFrame ? 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)' : '#F3F5F8', cursor: firstFrame && lastFrame ? 'pointer' : 'not-allowed', color: firstFrame && lastFrame ? '#fff' : '#98A2B3', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: firstFrame && lastFrame ? '0 6px 14px rgba(139, 92, 246, 0.24)' : 'none', transition: 'all 0.2s ease', flexShrink: 0 }}
onMouseEnter={(e) => { if (firstFrame && lastFrame) e.currentTarget.style.transform = 'scale(1.06)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
>
<SwapOutlined style={{ fontSize: 14 }} />
</button>
{/* 尾帧 */}
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden', opacity: !firstFrame ? 0.5 : 1 }}>
<div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>尾帧</span>
</div>
{lastFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
alt={lastFrame.name}
onClick={() => {
setAttachmentPreviewUrl(lastFrame.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(lastFrame.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8, cursor: 'pointer', border: '1px solid rgba(139, 92, 246, 0.2)', boxShadow: '0 3px 10px rgba(47, 52, 64, 0.06)' }}
/>
<button
onClick={handleRemoveLastFrame}
style={{ position: 'absolute', top: -6, right: -6, width: 18, height: 18, border: 'none', background: '#A45B5B', borderRadius: 50, cursor: 'pointer', color: '#fff', fontSize: 9, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 4px rgba(164, 91, 91, 0.3)', zIndex: 10 }}
>
<DeleteOutlined style={{ fontSize: 9 }} />
</button>
</div>
) : (
firstFrame ? (
<UploadSelector
accept="image/*"
multiple={false}
onLocalSelect={(files) => {
if (files.length > 0) {
handleUpload(files[0], 'last');
}
}}
onHistorySelect={(items) => {
if (items.length > 0) {
const item = items[0];
const mediaRef: MediaReference = {
name: item.fileName || '',
type: 'image',
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
role: 'last_frame',
label: '',
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
}
}}
onPortraitSelect={(assets) => {
if (assets.length > 0) {
const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = {
name: asset.name || '尾帧图片',
type: 'image',
url: previewUrl,
role: 'last_frame',
label: '',
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
}
}}
uploading={uploading}
>
<div
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.24)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.24)'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)'; }}
>
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传尾帧</span>
</div>
</UploadSelector>
) : (
<div
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(226, 232, 240, 0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'not-allowed', backgroundColor: 'rgba(241, 245, 249, 0.5)', flexDirection: 'column', gap: 4 }}
>
<PlusOutlined style={{ fontSize: 18, color: '#cbd5e1' }} />
<span style={{ fontSize: 12, color: '#94a3b8', fontWeight: 500 }}>请先上传首帧</span>
</div>
)
)}
</div>
</div>) : (
<>
{/* 上传区域 - 图片堆叠展示,右下角圆形+按钮 */}
{!(mediaType === 'video' && referenceMode === 'first_last_frame') && (
<div
style={{
position: 'relative',
width: 64,
height: 72,
marginTop: '-10',
}}
onMouseEnter={openMediaStackTray}
onMouseLeave={closeMediaStackTray}
>
{/* 没有上传时的卡片样式 */}
{currentMedia.length === 0 && (
<UploadSelector
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
onLocalSelect={handleBatchUpload}
onHistorySelect={handleUploadResourceHistorySelected}
onPortraitLibrarySelect={(libraryType) => {
openPrivatePortraitPicker(libraryType);
}}
uploading={uploading}
tooltipTitle={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}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
maxVideoDuration={15}
maxAudioCount={maxAudio > 0 ? maxAudio : undefined}
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'}
>
<div
style={{
width: 54,
height: 74,
borderRadius: 7,
border: '1px solid rgba(231, 234, 240, 0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
background: '#f4f4f4',
flexDirection: 'column',
gap: 5,
transform: 'rotate(-7deg)',
// boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.background = '#f4f4f4';
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
// e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
e.currentTarget.style.background = '#f4f4f4';
e.currentTarget.style.transform = 'rotate(-7deg)';
// e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
) : (
<>
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span></span><span></span></span>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span></span><span></span></span>
</div>
</>
)}
</div>
</UploadSelector>
)}
{/* {mediaType === 'video' && currentMedia.length === 0 && (
<Button
size="small"
onClick={() => setPrivateAssetPickerOpen(true)}
style={{ marginTop: 8, borderRadius: 8, color: '#8b5cf6', borderColor: '#ddd6fe', background: '#fff' }}
>
真人素材库
</Button>
)} */}
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
{currentMedia.length > 0 && (
<>
{currentMedia.map((media, idx) => {
const reversedIdx = Math.min(currentMedia.length, 3) - 1 - idx;
const offsetX = mediaStackHovered ? idx * 58 : (idx < 3 ? idx * 6 : 18);
const isVisible = !mediaStackHovered ? idx < 3 : true;
return (
<div
key={`stack-${idx}`}
style={{
position: 'absolute',
left: offsetX,
bottom: 0,
zIndex: idx + 1,
transition: 'all 0.28s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mediaStackHovered ? `scale(1) rotate(0deg)` : `scale(${idx < 3 ? (1 - reversedIdx * 0.08) : 0}) rotate(${idx < 3 ? (-3 + idx * 1.5) : 0}deg)`,
opacity: isVisible ? (mediaStackHovered ? 1 : (1 - reversedIdx * 0.15)) : 0,
pointerEvents: isVisible ? 'auto' : 'none',
}}
>
<div style={{ position: 'relative', width: 52, height: 60 }}>
{media.type === 'image' ? (
<img
src={buildPreviewUrl(media.url)}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
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)' }}
/>
) : media.type === 'video' ? (
<video
src={buildPreviewUrl(media.url)}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
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)' }}
/>
) : (
<div
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' }}
>
{playingAudioUrl === (buildPreviewUrl(media.url)) ? (
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
)}
</div>
)}
{/* 右上角删除按钮 */}
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: -6,
right: -6,
width: 20,
height: 20,
borderRadius: 50,
background: '#1f2937',
border: 'none',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
zIndex: 10,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1f2937';
e.currentTarget.style.transform = 'scale(1)';
}}
>
<DeleteOutlined style={{ fontSize: 10 }} />
</button>
</div>
</div>
);
})}
</>
)}
{/* 右下角圆形+上传按钮 */}
{currentMedia.length > 0 && (
<UploadSelector
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
onLocalSelect={handleBatchUpload}
onHistorySelect={handleUploadResourceHistorySelected}
onPortraitLibrarySelect={(libraryType) => {
openPrivatePortraitPicker(libraryType);
}}
uploading={uploading}
tooltipTitle={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}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
maxVideoDuration={15}
maxAudioCount={maxAudio > 0 ? maxAudio : undefined}
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'}
>
<div
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: 28,
height: 28,
borderRadius: 50,
background: '#ffffff',
border: '1px solid rgba(231, 234, 240, 0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
zIndex: 100,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#8b5cf6';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(139, 92, 246, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.08)';
}}
>
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
</div>
</UploadSelector>
)}
{/* {mediaType === 'video' && currentMedia.length > 0 && (
<Tooltip title="选择真人素材库">
<div
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
style={{
position: 'absolute',
right: -32,
bottom: 0,
width: 28,
height: 28,
borderRadius: 50,
background: '#fff',
border: '1px solid #ddd6fe',
color: '#8b5cf6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
fontWeight: 800,
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
zIndex: 100,
}}
>
</div>
</Tooltip>
)} */}
</div>
)}
</>
)}
</div>
{/* 右侧输入区域 */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* 输入框区域 */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: '0',
backgroundColor: 'transparent',
borderRadius: 0,
border: 'none',
boxShadow: 'none',
transition: 'all 0.2s ease',
position: 'relative',
minHeight: isFirstLastFrameComposer ? 92 : 74,
}}>
{/* {!inputValue.trim() && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minHeight: 22, paddingTop: 1 }}>
<span style={{ fontSize: 13, fontWeight: 800, color: '#475467', letterSpacing: 0.1, whiteSpace: 'nowrap' }}>{composerModeLabel}</span>
<span style={{ fontSize: 13, color: '#667085', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500 }}>{composerHelperText}</span>
</div>
)} */}
{/* 文本输入框 */}
<TextArea
ref={mentionInputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const file = items[i].getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
imageFiles.forEach(async (file) => {
await handleUpload(file);
});
}
}}
placeholder={composerPlaceholder}
autoSize={{ minRows: 3, maxRows: 6 }}
style={{
width: '100%',
borderRadius: 0,
border: 'none',
outline: 'none',
boxShadow: 'none',
fontSize: 14,
lineHeight: 1.72,
color: '#2f3440',
fontWeight: 500,
resize: 'none',
backgroundColor: 'transparent',
padding: 10,
}}
disabled={loading}
/>
{/* @ 提及下拉列表 */}
{mentionVisible && currentMedia.length > 0 && referenceMode === 'universal' && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 10,
marginBottom: 8,
zIndex: 1000,
background: '#fff',
borderRadius: 16,
boxShadow: '0 18px 42px rgba(47, 52, 64, 0.12)',
border: '1px solid #E7EAF0',
padding: 8,
minWidth: 160,
maxHeight: 260,
overflowY: 'auto',
}}
>
{currentMedia.map((media, idx) => (
<div
key={idx}
onClick={() => insertMention(media.label)}
style={{
padding: '8px 12px',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: '#2f3440',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.08)';
e.currentTarget.style.color = '#8b5cf6';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#2f3440';
}}
>
{media.type === 'image' ? (
<PictureOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
) : (
<VideoCameraOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
)}
<span>{media.label}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* 底部工具栏 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 14, marginTop: 10, paddingTop: 13, borderTop: '1px solid rgba(231, 234, 240, 0.76)' }}>
<Space size={8} wrap style={{ flex: 1, minWidth: 0 }}>
{/* <span>
创作类型:
</span> */}
{/* 文件类型选择器 */}
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowEngineModal(false);
setShowImageSettingsModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowMediaTypeModal(prev => !prev);
}}
className="image-settings-trigger"
style={{
minWidth: 118,
padding: '5px 12px',
height: 32,
borderRadius: 10,
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#FFFFFF';
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.06)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#E7EAF0';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(47, 52, 64, 0.03)';
}}
>
{mediaType === 'video' ? (
<VideoCameraOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
) : (
<PictureOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
)}
<Text style={{
fontSize: 13,
fontWeight: 500,
color: '#2f3440',
}}>
{mediaType === 'video' ? '视频生成' : '图片生成'}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button>
{showMediaTypeModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 300,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 16,
boxShadow: '0 20px 48px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 600,
color: '#475467',
}}>
创作类型
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
{[
{ value: 'video', label: '视频生成', icon: VideoCameraOutlined },
{ value: 'image', label: '图片生成', icon: PictureOutlined },
].map((item) => (
<button
key={item.value}
onClick={() => {
setMediaType(item.value);
setShowMediaTypeModal(false);
setCurrentMedia([]);
setInputValue('');
}}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: mediaType === item.value
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: mediaType === item.value
? '#f5f3ff'
: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
gap: 8,
}}
>
<item.icon style={{ fontSize: 14, color: mediaType === item.value ? '#8b5cf6' : '#98a2b3' }} />
<span style={{
fontSize: 14,
fontWeight: mediaType === item.value ? 600 : 500,
color: mediaType === item.value ? '#8b5cf6' : '#475467',
}}>
{item.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 引擎选择器 */}
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowMediaTypeModal(false);
setShowImageSettingsModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowEngineModal(prev => !prev);
}}
className="image-settings-trigger"
style={{
minWidth: 174,
padding: '5px 12px',
height: 32,
borderRadius: 10,
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#FFFFFF';
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.06)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#E7EAF0';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(47, 52, 64, 0.03)';
}}
>
<SettingOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<Text style={{
fontSize: 13,
fontWeight: 500,
color: '#2f3440',
}}>
{mediaType === 'image' ? enginesele.image?.find((e: any) => e.id === countType)?.name : enginesele.video?.find((e: any) => e.id === countType)?.name || '选择引擎'}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button>
{showEngineModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 360,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 16,
boxShadow: '0 20px 48px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择引擎 */}
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 600,
color: '#475467',
}}>
选择引擎
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
{(mediaType === 'image' ? enginesele.image : enginesele.video)?.map((engine: any) => (
<button
key={engine.id}
onClick={() => {
setCountType(engine.id);
if (mediaType === 'video') {
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);
}
}
if (mediaType === 'image') {
const supportedSizes = engine.supportedSizes || {};
const resolutionLevels = Object.keys(supportedSizes).sort((a, b) => {
const levelOrder = { '1K': 0, '2K': 1, '4K': 2 };
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
});
const primaryResolution = resolutionLevels[0] || '2K';
const supportedResolutions = Object.keys(supportedSizes[primaryResolution] || {});
const newRatioOptions = supportedResolutions.map((res: any) => ({
value: res,
label: res,
}));
setRatioOptions(newRatioOptions);
setCurrentEngineSupportedSizes(supportedSizes);
setResolutionOptions(resolutionLevels.map((level) => {
const match = level.match(/(\d+)K/);
const num = match ? parseInt(match[1]) : 1;
const labels: Record<number, string> = { 1: '标清', 2: '高清', 4: '超清' };
return {
value: level,
label: `${labels[num] || '高清'} ${level}`,
};
}));
if (supportedSizes[primaryResolution] && supportedSizes[primaryResolution][supportedResolutions[0]]) {
const defaultSize = supportedSizes[primaryResolution][supportedResolutions[0]];
const [w, h] = defaultSize.split(/[×x]/);
setWidth(Number(w));
setHeight(Number(h));
setSelectedRatio(supportedResolutions[0]);
setSelectedResolution(primaryResolution);
}
}
setShowEngineModal(false);
}}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: countType === engine.id
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: countType === engine.id
? '#f5f3ff'
: '#ffffff',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
}}
>
<span style={{
fontSize: 14,
fontWeight: countType === engine.id ? 600 : 500,
color: countType === engine.id ? '#8b5cf6' : '#475467',
marginBottom: 2,
}}>
{engine.name}
</span>
<span style={{
fontSize: 11,
color: '#98a2b3',
}}>
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 参考模式切换按钮 - 仅视频模式显示 */}
{mediaType === 'video' && (supportsFirstLastFrame || supportsUniversalReference) && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowMediaTypeModal(false);
setShowEngineModal(false);
setShowImageSettingsModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(!referenceModeDropdownVisible);
}}
className="image-settings-trigger"
style={{
minWidth: 118,
padding: '6px 14px',
height: 32,
borderRadius: 10,
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#FFFFFF';
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.06)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#E7EAF0';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(47, 52, 64, 0.03)';
}}
>
<PictureOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<Text style={{
fontSize: 13,
fontWeight: 500,
color: '#2f3440',
}}>
{referenceMode === 'universal' ? '全能参考' : '首尾帧'}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button>
{referenceModeDropdownVisible && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 300,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 16,
boxShadow: '0 20px 48px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 600,
color: '#475467',
}}>
参考模式
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
<button
onClick={() => {
handleReferenceModeChange('universal');
setReferenceModeDropdownVisible(false);
}}
disabled={!supportsUniversalReference}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: referenceMode === 'universal'
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: referenceMode === 'universal'
? '#f5f3ff'
: '#ffffff',
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
gap: 8,
opacity: supportsUniversalReference ? 1 : 0.4,
}}
>
<PictureOutlined style={{ fontSize: 14, color: referenceMode === 'universal' ? '#8b5cf6' : '#98a2b3' }} />
<span style={{
fontSize: 14,
fontWeight: referenceMode === 'universal' ? 600 : 500,
color: referenceMode === 'universal' ? '#8b5cf6' : '#475467',
}}>
全能参考
</span>
{!supportsUniversalReference && (
<span style={{ fontSize: 10, color: '#98a2b3', marginLeft: 'auto' }}>不支持</span>
)}
</button>
<button
onClick={() => {
handleReferenceModeChange('first_last_frame');
setReferenceModeDropdownVisible(false);
}}
disabled={!supportsFirstLastFrame}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: referenceMode === 'first_last_frame'
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: referenceMode === 'first_last_frame'
? '#f5f3ff'
: '#ffffff',
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
gap: 8,
opacity: supportsFirstLastFrame ? 1 : 0.4,
}}
>
<SwapOutlined style={{ fontSize: 14, color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#98a2b3' }} />
<span style={{
fontSize: 14,
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#475467',
}}>
首尾帧
</span>
{!supportsFirstLastFrame && (
<span style={{ fontSize: 10, color: '#98a2b3', marginLeft: 'auto' }}>不支持</span>
)}
</button>
</div>
</div>
</div>
)}
</div>
)}
{/* 图片设置按钮 */}
{mediaType === 'image' && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowMediaTypeModal(false);
setShowEngineModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowImageSettingsModal(!showImageSettingsModal);
}}
className="image-settings-trigger"
style={{
minWidth: 156,
padding: '6px 14px',
height: 32,
borderRadius: 10,
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#FFFFFF';
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.06)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#E7EAF0';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(47, 52, 64, 0.03)';
}}
>
<LayoutOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<Text style={{
fontSize: 13,
fontWeight: 500,
color: '#2f3440',
}}>
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution == '2K' ? '2K高清' : '4K超清'} · {width}×{height}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button>
{showImageSettingsModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 480,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 14,
boxShadow: '0 18px 44px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择比例 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
选择比例
</Text>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
}}>
{ratioOptions.map((item: any) => (
<button
key={item.value}
onClick={() => {
setSelectedRatio(item.value);
calculateSizeFromRatione(item.label);
}}
style={{
flex: '0 0 calc(11.11% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
border: selectedRatio === item.value
? '2px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: selectedRatio === item.value
? '#f5f3ff'
: '#FFFFFF',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<div style={{
width: (() => {
const [w, h] = item.value === 'auto' ? [1, 1] : item.value.split(':').map(Number);
const maxSize = 18;
if (w >= h) return maxSize;
return Math.round(maxSize * (w / h));
})(),
height: (() => {
const [w, h] = item.value === 'auto' ? [1, 1] : item.value.split(':').map(Number);
const maxSize = 18;
if (h >= w) return maxSize;
return Math.round(maxSize * (h / w));
})(),
border: `2px solid ${selectedRatio === item.value ? '#8b5cf6' : '#98a2b3'}`,
borderRadius: 2,
marginBottom: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{item.value === 'auto' && (
<span style={{
fontSize: 7,
color: selectedRatio === item.value
? '#8b5cf6'
: '#98a2b3',
}}>
</span>
)}
</div>
<span style={{
fontSize: 9,
color: selectedRatio === item.value
? '#8b5cf6'
: '#667085',
fontWeight: selectedRatio === item.value ? 600 : 400,
}}>
{item.value}
</span>
</button>
))}
</div>
</div>
{/* 选择分辨率 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
选择分辨率
</Text>
<div style={{ display: 'flex', gap: 6 }}>
{resolutionOptions.map((item: any) => (
<button
key={item.value}
onClick={() => {
setSelectedResolution(item.value);
calculateSizeFromRatiotwo(item.value);
}}
style={{
flex: 1,
height: 42,
borderRadius: 6,
border: selectedResolution === item.value
? '2px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: selectedResolution === item.value
? '#8b5cf6'
: '#FFFFFF',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<span style={{
fontSize: 12,
fontWeight: 600,
color: selectedResolution === item.value
? '#fff'
: '#475467',
}}>
{item.value}
{item.value === '4K' && (
<span style={{ marginLeft: 2 }}></span>
)}
</span>
</button>
))}
</div>
</div>
{/* 尺寸 */}
<div>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
尺寸
</Text>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
flex: 1,
maxWidth: 120,
}}>
<span style={{
color: '#98a2b3',
fontSize: 11,
marginRight: 4,
}}>
W
</span>
<Input
value={width}
onChange={(e) => setWidth(Number(e.target.value) || 0)}
style={{
flex: 1,
textAlign: 'center',
borderRadius: 4,
height: 34,
border: '1px solid #E7EAF0',
backgroundColor: '#FFFFFF',
fontSize: 12,
fontWeight: 600,
color: '#2f3440',
}}
/>
</div>
<button
onClick={handleSwap}
style={{
width: 24,
height: 24,
borderRadius: 4,
border: '1px solid #E7EAF0',
backgroundColor: '#fff',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: '#8b5cf6',
}}
>
<SwapOutlined style={{ fontSize: 10 }} />
</button>
<div style={{
display: 'flex',
alignItems: 'center',
flex: 1,
maxWidth: 120,
}}>
<span style={{
color: '#98a2b3',
fontSize: 11,
marginRight: 4,
}}>
H
</span>
<Input
value={height}
onChange={(e) => setHeight(Number(e.target.value) || 0)}
style={{
flex: 1,
textAlign: 'center',
borderRadius: 4,
height: 34,
border: '1px solid #E7EAF0',
backgroundColor: '#FFFFFF',
fontSize: 12,
fontWeight: 600,
color: '#2f3440',
}}
/>
</div>
<span style={{ color: '#98a2b3', fontSize: 11 }}>
PX
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* 视频参数设置 */}
{mediaType === 'video' && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowMediaTypeModal(false);
setShowEngineModal(false);
setShowImageSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowVideoSettingsModal(!showVideoSettingsModal);
}}
className="image-settings-trigger"
style={{
minWidth: 156,
padding: '6px 14px',
height: 32,
borderRadius: 10,
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#FFFFFF';
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.06)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#E7EAF0';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(47, 52, 64, 0.03)';
}}
>
<LayoutOutlined style={{ fontSize: 14, color: '#2f3440' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoAspectRatio}</span>
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span>
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}</span>
<CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} />
</button>
{showVideoSettingsModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 400,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 14,
boxShadow: '0 18px 44px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择比例 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
选择比例
</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 #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: videoAspectRatio === ratio
? '#f5f3ff'
: '#FFFFFF',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<div style={{
width: (() => {
const [w, h] = ratio.split(':').map(Number);
const maxSize = 18;
if (w >= h) return maxSize;
return Math.round(maxSize * (w / h));
})(),
height: (() => {
const [w, h] = ratio.split(':').map(Number);
const maxSize = 18;
if (h >= w) return maxSize;
return Math.round(maxSize * (h / w));
})(),
border: `2px solid ${videoAspectRatio === ratio ? '#8b5cf6' : '#98a2b3'}`,
borderRadius: 2,
marginBottom: 2,
}} />
<span style={{
fontSize: 9,
color: videoAspectRatio === ratio
? '#8b5cf6'
: '#667085',
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: '#667085',
}}>
选择时长
</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: '#E7EAF0',
transform: 'translateY(-50%)',
}} />
{/* 已滑动部分 */}
<div style={{
position: 'absolute',
top: '50%',
left: 0,
height: 6,
borderRadius: 3,
background: '#8b5cf6',
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: '#F6F7FA',
borderRadius: 6,
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#667085' }}>
{videoDuration}
</span>
<span style={{ fontSize: 12, color: '#98a2b3' }}></span>
</div>
</div>
</div>
{/* 选择分辨率 */}
<div>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
选择分辨率
</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 #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: videoResolution === resolution
? '#8b5cf6'
: '#FFFFFF',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<span style={{
fontSize: 12,
fontWeight: 600,
color: videoResolution === resolution
? '#fff'
: '#475467',
}}>
{resolution}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#667085', whiteSpace: 'nowrap', height: 34, padding: '0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}>预估积分:</Text>
<Text style={{ fontSize: 13, color: '#2f3440', fontWeight: 800 }}>{getEstimatedCredits()}</Text>
</div>
<Button
type="primary"
shape="circle"
icon={<ArrowUpOutlined />}
onClick={handleSend}
disabled={!composerCanSend}
loading={loading}
style={{
flexShrink: 0,
width: 36,
height: 36,
borderRadius: '50%',
background: composerCanSend
? 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)'
: '#E7EAF0',
color: '#ffffff',
opacity: composerCanSend ? 1 : 0.86,
boxShadow: composerCanSend ? '0 10px 24px rgba(139, 92, 246, 0.28)' : 'inset 0 0 0 1px rgba(139, 92, 246, 0.18)',
cursor: composerCanSend ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease',
border: 'none',
}}
/>
</div>
</div>
</div>
</Content>
</Layout>
{/* 自定义CSS动画 - 加载中闪烁效果 */}
<style>{`
.ai-create-page {
--ai-accent: #8b5cf6;
--ai-accent-soft: #f5f3ff;
--ai-accent-mid: #a78bfa;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0.3; }
}
textarea::placeholder {
color: #98a2b3 !important;
line-height: 1.72 !important;
font-weight: 500 !important;
white-space: normal !important;
}
.ai-create-scroll::-webkit-scrollbar {
width: 8px;
}
.ai-create-scroll::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.32);
border-radius: 999px;
}
.ai-create-scroll::-webkit-scrollbar-track {
background: transparent;
}
.ai-composer-select .ant-select-selector {
height: 32px !important;
border-radius: 11px !important;
border-color: #E7EAF0 !important;
box-shadow: 0 1px 3px rgba(47, 52, 64, 0.035) !important;
background: rgba(255,255,255,0.86) !important;
}
.ai-composer-select .ant-select-selection-item {
display: flex !important;
align-items: center !important;
font-weight: 700 !important;
color: #2f3440 !important;
}
.ant-select-dropdown {
border-radius: 16px !important;
box-shadow: 0 20px 48px rgba(47, 52, 64, 0.12) !important;
border: 1px solid #E7EAF0 !important;
padding: 8px !important;
}
.ant-select-dropdown .ant-select-item {
border-radius: 10px !important;
min-height: 36px !important;
color: #2f3440 !important;
font-weight: 600 !important;
}
.ant-select-dropdown .ant-select-item-option-active,
.ant-select-dropdown .ant-select-item-option-selected {
background: #f5f3ff !important;
color: #8b5cf6 !important;
}
.image-settings-popover {
animation: composerPopoverIn 0.16s ease-out;
}
@keyframes composerPopoverIn {
from { opacity: 0; transform: translateY(6px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
{/* 图片/视频预览弹窗 */}
<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)' }}
disabled={isMediaExpired(previewUrl)}
>
{isMediaExpired(previewUrl) ? '资源已过期' : '下载'}
</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%' }}>
{isMediaExpired(previewUrl) ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<p style={{ fontSize: 16, color: '#A45B5B', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
</div>
) : previewType === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewUrl}&w=300&q=50`}
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>
{/* 附件预览弹窗(独立弹窗) */}
<Modal
open={attachmentPreviewVisible}
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={() => {
// 关闭前停止视频播放
if (attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current;
v.pause();
v.muted = true;
v.currentTime = 0;
}
setAttachmentPreviewVisible(false);
}}
afterClose={() => {
// Modal 完全关闭后再次确保视频被停止
if (attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current;
v.pause();
v.muted = true;
}
}}
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={() => {
if (!attachmentPreviewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}&download=1`;
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
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%' }}>
{attachmentPreviewType === 'image' ? (
<img
src={buildPreviewUrl(attachmentPreviewUrl)}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={attachmentPreviewVideoRef}
src={buildPreviewUrl(attachmentPreviewUrl)}
controls
playsInline
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
<PrivatePortraitAssetPicker
open={privateAssetPickerOpen}
libraryType={privateAssetPickerLibraryType}
onClose={() => setPrivateAssetPickerOpen(false)}
onSelect={handlePrivatePortraitAssetsSelected}
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
maxCount={Math.max(1, maxImage + maxVideo)}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
maxVideoDuration={15}
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
/>
</Layout>
);
};
export default AIChatPage;