4576 lines
204 KiB
TypeScript
4576 lines
204 KiB
TypeScript
|
||
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;
|