2414 lines
95 KiB
TypeScript
2414 lines
95 KiB
TypeScript
|
||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||
|
||
import {
|
||
Layout,
|
||
Button,
|
||
Input,
|
||
Select,
|
||
Upload,
|
||
message,
|
||
Space,
|
||
Typography,
|
||
Tooltip,
|
||
Popconfirm,
|
||
Modal,
|
||
} from 'antd';
|
||
|
||
import {
|
||
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
|
||
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
|
||
} from '../api';
|
||
|
||
import {
|
||
PlusOutlined,
|
||
MenuUnfoldOutlined,
|
||
MenuFoldOutlined,
|
||
SendOutlined,
|
||
DeleteOutlined,
|
||
RobotOutlined,
|
||
LoadingOutlined,
|
||
PictureOutlined,
|
||
VideoCameraOutlined,
|
||
CaretDownOutlined,
|
||
SwapOutlined,
|
||
WarningOutlined,
|
||
SettingOutlined,
|
||
LayoutOutlined,
|
||
|
||
} 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';
|
||
url: 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 [collapsed, setCollapsed] = useState<boolean>(false);
|
||
|
||
const [currentConversationId, setCurrentConversationId] = useState<string | null>('1');
|
||
|
||
const [conversations, setConversations] = useState<Conversation[]>([
|
||
]);
|
||
|
||
|
||
|
||
const [inputValue, setInputValue] = useState<string>('');
|
||
|
||
const [mediaType, setMediaType] = useState<string>('image');
|
||
|
||
const [countType, setCountType] = useState<string>('请选择');
|
||
|
||
const [uploading, setUploading] = useState<boolean>(false);
|
||
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
|
||
const [currentMedia, setCurrentMedia] = useState<{ name: string; type: 'image' | 'video'; url: string }[]>([]);
|
||
|
||
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
|
||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
|
||
// 从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'>('image');
|
||
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
||
|
||
// 附件详情悬浮窗状态
|
||
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
|
||
const [attachmentPopupMessageId, setAttachmentPopupMessageId] = useState<string | null>(null);
|
||
const [attachmentPopupPosition, setAttachmentPopupPosition] = useState({ x: 0, y: 0 });
|
||
|
||
// 图片设置相关状态
|
||
const [selectedRatio, setSelectedRatio] = useState<string>('1:1');
|
||
const [selectedResolution, setSelectedResolution] = useState<string>('2K');
|
||
const [width, setWidth] = useState<number>(2048);
|
||
const [height, setHeight] = useState<number>(2048);
|
||
const [showImageSettingsModal, setShowImageSettingsModal] = useState(false);
|
||
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
|
||
const [ratioOptions, setRatioOptions] = useState([]);
|
||
const [resolutionOptions, setResolutionOptions] = useState([]);
|
||
const [widthandheight, setWidthandHeight] = useState([]);
|
||
const [blindex, setBlindex] = useState<any>(0);
|
||
const [fblindex, setFBlindex] = useState<any>(0);
|
||
|
||
// 视频设置相关状态
|
||
const [videoDuration, setVideoDuration] = useState(5);
|
||
const [videoAspectRatio, setVideoAspectRatio] = useState<string>('16:9');
|
||
const [videoResolution, setVideoResolution] = useState<string>('720p');
|
||
const [showEngineModal, setShowEngineModal] = useState(false);
|
||
const [engineOptions, setEngineOptions] = useState<{
|
||
ratios: string[];
|
||
resolutions: string[];
|
||
durations: number[];
|
||
}>({
|
||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||
resolutions: ['480p', '720p', '1080p'],
|
||
durations: [5, 8, 10, 12, 15],
|
||
});
|
||
|
||
const [enginesele, setEnginesele] = useState<any>([]);
|
||
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,
|
||
};
|
||
if (videoResolution === '1080p') {
|
||
config.ratio = 2;
|
||
} else if (videoResolution === '720p') {
|
||
config.ratio = 1.5;
|
||
} else if (videoResolution === '480p') {
|
||
config.ratio = 1;
|
||
}
|
||
}
|
||
else {
|
||
// 如果没有找到对应的配置,则使用默认配置
|
||
config = {
|
||
perSecondCredits: 0.1,
|
||
baseCredits: 4,
|
||
ratio: 1,
|
||
};
|
||
if (selectedResolution === '2K') {
|
||
config.ratio = 1;
|
||
} else if (selectedResolution === '4K') {
|
||
config.ratio = 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
console.log(config);
|
||
|
||
|
||
// 根据配置计算积分
|
||
if (mediaType === 'video') {
|
||
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
|
||
return Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
||
} else {
|
||
// 图片:baseCredits × ratio
|
||
return config.baseCredits * config.ratio;
|
||
}
|
||
};
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
const pollingRef = useRef<number | null>(null);
|
||
const isFirstLoadRef = useRef<boolean>(true);
|
||
|
||
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);
|
||
const isInitialLoadDone = useRef(false);
|
||
// ==================== 副作用 ====================
|
||
|
||
// 滚动到底部的辅助函数
|
||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => {
|
||
const scrollContainer = scrollContainerRef.current;
|
||
if (!scrollContainer) return;
|
||
|
||
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 (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);
|
||
};
|
||
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
|
||
|
||
// 初始化获取参数 - 只在组件挂载时执行一次
|
||
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];
|
||
setEngineOptions({
|
||
ratios: defaultEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||
resolutions: defaultEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||
durations: defaultEngine.supportedDurations || [5, 8, 10, 12, 15],
|
||
});
|
||
// 设置默认选中值
|
||
if (defaultEngine.supportedRatios?.length > 0) {
|
||
setVideoAspectRatio(defaultEngine.supportedRatios[0]);
|
||
}
|
||
if (defaultEngine.supportedResolutions?.length > 0) {
|
||
setVideoResolution(defaultEngine.supportedResolutions[0]);
|
||
}
|
||
if (defaultEngine.supportedDurations?.length > 0) {
|
||
setVideoDuration(defaultEngine.supportedDurations[0]);
|
||
}
|
||
// 设置默认引擎
|
||
setCountType(defaultEngine.id);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
});
|
||
getCreditRatios()
|
||
.then((data: any) => {
|
||
// console.log('积分', data);
|
||
|
||
setCreditRatios(data.video || []);
|
||
setCimage(data.image || []);
|
||
})
|
||
.catch((error) => {
|
||
});
|
||
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) => {
|
||
});
|
||
}, 5000);
|
||
|
||
// 清理定时器
|
||
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]);
|
||
|
||
// 监听 blindex 状态变化
|
||
useEffect(() => {
|
||
if (
|
||
widthandheight[fblindex] &&
|
||
widthandheight[fblindex][blindex] !== undefined
|
||
) {
|
||
setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4)));
|
||
setHeight(Number(widthandheight[fblindex][blindex].substring(5)));
|
||
}
|
||
}, [blindex]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
widthandheight[fblindex] &&
|
||
widthandheight[fblindex][blindex] !== undefined
|
||
) {
|
||
setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4)));
|
||
setHeight(Number(widthandheight[fblindex][blindex].substring(5)));
|
||
}
|
||
}, [fblindex]);
|
||
|
||
// 根据比例计算尺寸
|
||
const calculateSizeFromRatione = (ratio: string) => {
|
||
setBlindex(ratio);
|
||
};
|
||
|
||
const calculateSizeFromRatiotwo = (ratio: string) => {
|
||
setFBlindex(parseInt(ratio === '2K' ? '0' : '1'));
|
||
};
|
||
|
||
// 交换宽高
|
||
const handleSwap = () => {
|
||
setWidth(height);
|
||
setHeight(width);
|
||
};
|
||
|
||
// ==================== 事件处理函数 ====================
|
||
|
||
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([]);
|
||
// 显示提示消息
|
||
message.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
|
||
);
|
||
}
|
||
// 显示成功提示
|
||
message.success('对话已删除');
|
||
};
|
||
|
||
|
||
const handleSelectChat = (conversationId: string) => {
|
||
setCurrentConversationId(conversationId);
|
||
setCurrentMedia([]);
|
||
};
|
||
|
||
|
||
const handleSend = async () => {
|
||
// 验证:必须有内容或图片或视频
|
||
if (!inputValue.trim() && currentMedia.length === 0) {
|
||
message.warning('请输入内容或上传图片/视频');
|
||
return;
|
||
}
|
||
|
||
// 创建用户消息对象
|
||
const newMessage: Message = {
|
||
id: '',
|
||
gen_type: mediaType,
|
||
original_prompt: inputValue.trim(),
|
||
engine_id: countType,
|
||
|
||
idempotency_key: new Date().toLocaleString('zh-CN'),
|
||
// 统一的媒体数组,包含 name、type、url
|
||
media_references: currentMedia.length > 0 ? [...currentMedia] : undefined,
|
||
// 图片参数(仅图片模式时添加)
|
||
...(mediaType === 'image' && {
|
||
image_size: selectedResolution,
|
||
image_proportion: selectedRatio,
|
||
image_px: width + "x" + height,
|
||
}),
|
||
// 视频参数(仅视频模式时添加)
|
||
...(mediaType === 'video' && {
|
||
duration: videoDuration,
|
||
ratio: videoAspectRatio,
|
||
resolution: videoResolution,
|
||
}),
|
||
};
|
||
|
||
|
||
// 设置加载状态
|
||
setLoading(true);
|
||
|
||
createGenerationTask(newMessage).then(() => {
|
||
// 创建任务成功后,清空输入框和已上传媒体
|
||
setInputValue('');
|
||
setCurrentMedia([]);
|
||
|
||
// 创建任务成功后,重置页数为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;
|
||
}
|
||
|
||
message.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 handleUpload = async (file: File) => {
|
||
// 验证文件类型
|
||
const isImage = file.type.startsWith('image/');
|
||
const isVideo = file.type.startsWith('video/');
|
||
|
||
if (!isImage && !isVideo) {
|
||
message.error('仅支持图片或视频文件');
|
||
return false;
|
||
}
|
||
|
||
// 验证文件大小
|
||
const maxMB = isVideo ? 100 : 10;
|
||
if (file.size / 1024 / 1024 > maxMB) {
|
||
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
|
||
return false;
|
||
}
|
||
|
||
// 验证图片数量(最多4张)
|
||
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
||
if (isImage && imageCount >= 4) {
|
||
message.error('最多上传4张图片');
|
||
return false;
|
||
}
|
||
|
||
// 验证视频数量(最多1个)
|
||
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
|
||
if (isVideo && videoCount >= 1) {
|
||
message.error('最多上传1个视频');
|
||
return false;
|
||
}
|
||
|
||
// 设置上传状态
|
||
setUploading(true);
|
||
|
||
try {
|
||
// 根据文件类型调用相应的上传函数
|
||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||
const res = await uploadFn(file);
|
||
// 添加到统一的媒体列表
|
||
setCurrentMedia((prev) => [...prev, {
|
||
name: file.name,
|
||
type: isImage ? 'image' : 'video',
|
||
url: res.url,
|
||
}]);
|
||
message.success(`${isImage ? '图片' : '视频'}上传成功`);
|
||
} catch (error) {
|
||
message.error('上传失败');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
|
||
// 返回false阻止Ant Design的自动上传行为
|
||
return false;
|
||
};
|
||
|
||
|
||
const handleRemoveMedia = (index: number) => {
|
||
setCurrentMedia((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
|
||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
|
||
|
||
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}`;
|
||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
// ==================== 渲染 ====================
|
||
|
||
return (
|
||
<Layout style={{ height: '94vh', background: '#fafafa', overflow: 'hidden' }}>
|
||
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
|
||
{false && (
|
||
<Sider
|
||
trigger={null}
|
||
collapsible
|
||
collapsed={collapsed}
|
||
width={220}
|
||
style={{
|
||
background: '#fff',
|
||
borderRight: '1px solid #f0f0f0',
|
||
}}
|
||
>
|
||
<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: 8,
|
||
cursor: 'pointer',
|
||
background: currentConversationId === conversation.id ? '#f0f5ff' : 'transparent',
|
||
border: currentConversationId === conversation.id ? '1px solid #e0e8ff' : 'none',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : '#fafafa';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : 'transparent';
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#333', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{conversation.title}
|
||
</p>
|
||
<p style={{ margin: 2, fontSize: 11, color: '#999', 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: '#999', padding: 4 }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</Popconfirm>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Sider>
|
||
)}
|
||
|
||
{/* 主内容区 */}
|
||
<Layout style={{ flex: 1 }}>
|
||
{/* 头部 - 显示对话标题和模型信息 */}
|
||
<Header
|
||
style={{
|
||
background: '#fff',
|
||
padding: '0 24px',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<div>
|
||
<Text strong style={{ fontSize: 16 }}>
|
||
{'开启创作'}
|
||
</Text>
|
||
</div>
|
||
{/* <div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#999' }}>
|
||
<span>模型: Gemini 3 Pro</span>
|
||
<span>比例: 9:16</span>
|
||
<span style={{ color: '#6366f1', fontWeight: 500 }}>消耗 15 积分</span>
|
||
</div> */}
|
||
</Header>
|
||
|
||
{/* 消息区域 */}
|
||
<Content
|
||
style={{
|
||
margin: 0,
|
||
background: '#fafafa',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
padding: 24,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{/* 空状态 - 没有对话或当前对话没有消息时显示 */}
|
||
{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, #6366f1 0%, #a78bfa 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: '#333' }}>
|
||
你好,想创作什么?
|
||
</h2>
|
||
<p style={{ margin: 8, fontSize: 13, color: '#999' }}>
|
||
输入想法、剧本或上传参考,开始你的创作之旅
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 消息列表 - gen_list 有数据时显示 */}
|
||
{gen_list.length > 0 && (
|
||
<div
|
||
ref={scrollContainerRef}
|
||
style={{
|
||
height: 'calc(100vh - 280px)', // 固定高度,减去头部和输入区域
|
||
overflowY: 'auto', // 垂直滚动
|
||
paddingBottom: 24,
|
||
paddingRight: 8, // 预留滚动条空间
|
||
}}
|
||
>
|
||
{/* 加载更多按钮 - 在列表顶部 */}
|
||
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
|
||
{gen_list.length >= Totalnumber ? (
|
||
<span style={{ fontSize: 12, color: '#999' }}>消息全部加载</span>
|
||
) : (
|
||
<button
|
||
onClick={handleLoadMore}
|
||
disabled={isLoadingMore}
|
||
style={{
|
||
fontSize: 12,
|
||
color: '#6366f1',
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
{isLoadingMore ? '加载中...' : '加载更多'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{/* 遍历消息列表 */}
|
||
{(() => {
|
||
const msgApi = message;
|
||
return gen_list.map((msg) => (
|
||
<div
|
||
key={msg.id}
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'flex-start', // 所有消息左对齐
|
||
marginBottom: 16,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', gap: 10, maxWidth: '70%' }}>
|
||
{/* 头像 */}
|
||
<div
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 50,
|
||
background: '#e0e0e0',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<RobotOutlined style={{ color: '#666', fontSize: 16 }} />
|
||
</div>
|
||
|
||
{/* 消息内容 */}
|
||
<div>
|
||
{/* 时间戳和参数信息 */}
|
||
|
||
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
|
||
{/* 引擎标签 */}
|
||
<span >
|
||
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
|
||
{msg.engineSnapshot.name}
|
||
</span>
|
||
{/* 参数标签 */}
|
||
<span >
|
||
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
|
||
{msg.genType === 'image'
|
||
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
|
||
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
|
||
}
|
||
</span>
|
||
<span style={{ marginLeft: 8 }}>消耗积分:{msg.creditsCost}</span>
|
||
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
|
||
<span
|
||
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
|
||
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={{
|
||
background: '#fff',
|
||
borderRadius: '16px 16px 16px 4px',
|
||
padding: '12px 16px',
|
||
width: '600px',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
{/* 删除按钮 - 右上角 */}
|
||
<div style={{ position: 'absolute', top: 8, right: 8 }}>
|
||
<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) {
|
||
console.log(error);
|
||
|
||
const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
|
||
msgApi.error(errorMsg);
|
||
}
|
||
}}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<button
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
background: 'rgba(0,0,0,0.05)',
|
||
color: '#999',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transition: 'all 0.2s ease',
|
||
padding: 0,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#fff2f0';
|
||
e.currentTarget.style.color = '#ff4d4f';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = 'rgba(0,0,0,0.05)';
|
||
e.currentTarget.style.color = '#999';
|
||
}}
|
||
>
|
||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||
</button>
|
||
</Popconfirm>
|
||
</div>
|
||
|
||
{/* 文本内容 */}
|
||
<Tooltip
|
||
title={msg.originalPrompt}
|
||
placement="top"
|
||
style={{ maxWidth: '400px' }}
|
||
>
|
||
<p style={{
|
||
margin: '8px 0',
|
||
fontSize: 14,
|
||
color: '#475569',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
cursor: 'pointer',
|
||
padding: '4px 8px',
|
||
borderRadius: 4,
|
||
transition: 'background-color 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f1f5f9'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||
>
|
||
{msg.originalPrompt}
|
||
</p>
|
||
</Tooltip>
|
||
|
||
{/* 根据 status 显示不同内容 */}
|
||
{/* 生成中 - 显示加载动画 */}
|
||
{msg.status === 'generating' && (
|
||
<div style={{ height: 200, marginBottom: 10, marginTop: 12, borderRadius: 8, overflow: 'hidden', position: 'relative', backgroundColor: '#f1f5f9' }}>
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 16
|
||
}}>
|
||
<div style={{
|
||
width: 48,
|
||
height: 48,
|
||
border: '3px solid #e2e8f0',
|
||
borderTopColor: '#6366f1',
|
||
borderRadius: '50%',
|
||
animation: 'spin 1s linear infinite'
|
||
}} />
|
||
<span style={{ fontSize: 14, color: '#64748b' }}>正在生成中...</span>
|
||
</div>
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent)',
|
||
animation: 'shimmer 2s infinite'
|
||
}} />
|
||
</div>
|
||
)}
|
||
{/* 生成失败 - 显示失败提示 */}
|
||
{msg.status === 'failed' && (
|
||
<div style={{
|
||
height: 200,
|
||
marginBottom: 10,
|
||
marginTop: 12,
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
backgroundColor: '#fafafa',
|
||
border: '1px dashed #e2e8f0',
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 12
|
||
}}>
|
||
<div style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: '50%',
|
||
background: '#fff2f0',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}>
|
||
<WarningOutlined style={{ color: '#ff4d4f', fontSize: 22 }} />
|
||
</div>
|
||
<span style={{ fontSize: 14, color: '#94a3b8' }}>生成失败</span>
|
||
{/* <span style={{ fontSize: 12, color: '#cbd5e1' }}>请重试</span> */}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* 已完成 - 显示媒体内容 */}
|
||
{msg.status === 'completed' && (
|
||
<div style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}>
|
||
<div
|
||
onClick={() => {
|
||
setPreviewUrl(msg.genType === 'image' ? msg.imageUrl : msg.videoUrl);
|
||
setPreviewType(msg.genType === 'image' ? 'image' : 'video');
|
||
setPreviewVisible(true);
|
||
}}
|
||
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
|
||
>
|
||
{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: 8, objectFit: 'contain', backgroundColor: '#f1f5f9', transition: 'transform 0.2s' }}
|
||
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: 8, objectFit: 'contain', backgroundColor: '#f1f5f9', transition: 'transform 0.2s' }}
|
||
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(0,0,0,0.6)',
|
||
borderRadius: '50%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
pointerEvents: 'none',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||
}}>
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
))
|
||
})()}
|
||
|
||
|
||
{/* 消息列表底部标记 - 用于自动滚动 */}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 附件详情悬浮窗 - 包含遮罩层 */}
|
||
{attachmentPopupVisible && attachmentPopupMessageId && (
|
||
<>
|
||
{/* 遮罩层 - 点击关闭悬浮窗 */}
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'transparent',
|
||
zIndex: 9998,
|
||
}}
|
||
onClick={() => {
|
||
setAttachmentPopupVisible(false);
|
||
setAttachmentPopupMessageId(null);
|
||
}}
|
||
/>
|
||
{/* 悬浮窗内容 */}
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: attachmentPopupPosition.y - 100,
|
||
left: attachmentPopupPosition.x,
|
||
background: '#fff',
|
||
borderRadius: 12,
|
||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||
padding: 16,
|
||
minWidth: 200,
|
||
zIndex: 9999,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||
<span style={{ fontSize: 14, fontWeight: 500 }}>附件列表</span>
|
||
<button
|
||
onClick={() => {
|
||
setAttachmentPopupVisible(false);
|
||
setAttachmentPopupMessageId(null);
|
||
}}
|
||
style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: '50%',
|
||
border: 'none',
|
||
background: '#ff4d4f',
|
||
cursor: 'pointer',
|
||
fontSize: 14,
|
||
color: '#fff',
|
||
fontWeight: 'bold',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#ff7875';
|
||
e.currentTarget.style.transform = 'scale(1.1)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = '#ff4d4f';
|
||
e.currentTarget.style.transform = 'scale(1)';
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
{gen_list.find((msg: any) => msg.id === attachmentPopupMessageId)?.mediaReferences?.map((ref: any, idx: number) => (
|
||
<div
|
||
key={idx}
|
||
onClick={() => {
|
||
setAttachmentPreviewUrl(ref.url);
|
||
setAttachmentPreviewType(ref.url.includes('.mp4') || ref.url.includes('.mov') || ref.url.includes('.avi') || ref.url.includes('.video') ? 'video' : 'image');
|
||
setAttachmentPreviewName(ref.name);
|
||
setAttachmentPreviewVisible(true);
|
||
// 关闭附件悬浮窗,避免层级覆盖问题
|
||
setAttachmentPopupVisible(false);
|
||
setAttachmentPopupMessageId(null);
|
||
}}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '8px 12px',
|
||
borderRadius: 6,
|
||
cursor: 'pointer',
|
||
fontSize: 13,
|
||
color: '#333',
|
||
transition: 'background 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||
>
|
||
{ref.type === 'image' ? (
|
||
<PictureOutlined style={{ color: '#3b82f6', fontSize: 14 }} />
|
||
) : (
|
||
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 14 }} />
|
||
)}
|
||
{ref.name}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 输入区域 - 始终显示 */}
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 24,
|
||
padding: 16,
|
||
boxShadow: '0 4px 20px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06)',
|
||
transition: 'all 0.3s ease',
|
||
}}
|
||
>
|
||
{/* 已上传媒体预览 */}
|
||
{currentMedia.length > 0 && (
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
|
||
{currentMedia.map((media, idx) => (
|
||
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0 }}>
|
||
{media.type === 'image' ? (
|
||
<img
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||
alt={media.name}
|
||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||
/>
|
||
) : (
|
||
<video
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||
/>
|
||
)}
|
||
{/* 删除已上传媒体按钮 */}
|
||
<button
|
||
onClick={() => handleRemoveMedia(idx)}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
right: 0,
|
||
width: 20,
|
||
height: 20,
|
||
border: 'none',
|
||
background: '#ff4d4f',
|
||
borderRadius: 50,
|
||
cursor: 'pointer',
|
||
color: '#fff',
|
||
fontSize: 12,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入框区域 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: 12,
|
||
padding: '12px 16px',
|
||
backgroundColor: '#ffffff',
|
||
borderRadius: 16,
|
||
border: '1px solid #e2e8f0',
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||
transition: 'all 0.2s ease',
|
||
}}>
|
||
{/* 上传按钮 */}
|
||
<Upload
|
||
accept="image/*,video/*"
|
||
showUploadList={false}
|
||
beforeUpload={handleUpload}
|
||
>
|
||
<Tooltip title={`图片${currentMedia.filter(m => m.type === 'image').length}/4,视频${currentMedia.filter(m => m.type === 'video').length}/1`}>
|
||
<div
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 12,
|
||
border: '2px dashed #cbd5e1',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.25s ease',
|
||
flexShrink: 0,
|
||
backgroundColor: '#f8fafc',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = '#6366f1';
|
||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
|
||
e.currentTarget.style.transform = 'scale(1.05)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||
e.currentTarget.style.transform = 'scale(1)';
|
||
}}
|
||
>
|
||
{uploading ? (
|
||
<LoadingOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||
) : (
|
||
<PlusOutlined style={{ fontSize: 18, color: '#64748b' }} />
|
||
)}
|
||
</div>
|
||
</Tooltip>
|
||
</Upload>
|
||
|
||
{/* 文本输入框 */}
|
||
<TextArea
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="上传最多4张参考图,输入提示词描述您想生成的画面..."
|
||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||
style={{
|
||
flex: 1,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
outline: 'none',
|
||
boxShadow: 'none',
|
||
fontSize: 14,
|
||
lineHeight: 1.5,
|
||
color: '#1e293b',
|
||
resize: 'none',
|
||
}}
|
||
disabled={loading}
|
||
/>
|
||
|
||
{/* 发送按钮 */}
|
||
<Button
|
||
type="primary"
|
||
shape="circle"
|
||
icon={<SendOutlined />}
|
||
onClick={handleSend}
|
||
disabled={!inputValue.trim() && currentMedia.length === 0}
|
||
loading={loading}
|
||
style={{
|
||
flexShrink: 0,
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 14,
|
||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
|
||
transition: 'all 0.2s ease',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 底部选择器 - 媒体类型和数量 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #f0f0f0' }}>
|
||
<Space size="middle">
|
||
{/* 文件类型选择器 */}
|
||
{/* <div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
padding: '5px 12px',
|
||
borderRadius: 8,
|
||
background: '#f8f9fc',
|
||
border: '1px solid #e2e8f0',
|
||
height: 28,
|
||
}}> */}
|
||
{/* <Text style={{ fontSize: 12, color: '#94a3b8' }}>类型</Text> */}
|
||
<Select
|
||
value={mediaType}
|
||
onChange={(val) => setMediaType(val)}
|
||
style={{
|
||
width: 100,
|
||
outline: 'none',
|
||
background: '#f1f5f9',
|
||
border: 'none',
|
||
borderRadius: 8,
|
||
height: 34,
|
||
}}
|
||
size="middle"
|
||
>
|
||
<Option value="image">
|
||
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />
|
||
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}>图片</span>
|
||
</Option>
|
||
<Option value="video">
|
||
<VideoCameraOutlined style={{ marginRight: 6, fontSize: 14 }} />
|
||
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}>视频</span>
|
||
</Option>
|
||
</Select>
|
||
{/* </div> */}
|
||
|
||
{/* 引擎选择器 */}
|
||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||
<button
|
||
onClick={() => setShowEngineModal(true)}
|
||
className="image-settings-trigger"
|
||
style={{
|
||
minWidth: 200,
|
||
padding: '4px 12px',
|
||
height: 34,
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
backgroundColor: '#f1f5f9',
|
||
cursor: 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<SettingOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||
<Text style={{
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
color: '#64748b',
|
||
}}>
|
||
{mediaType === 'image' ? enginesele.image?.find((e: any) => e.id === countType)?.name : enginesele.video?.find((e: any) => e.id === countType)?.name || '选择引擎'}
|
||
</Text>
|
||
</button>
|
||
|
||
{showEngineModal && (
|
||
<div
|
||
className="image-settings-popover"
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 'calc(100% + 8px)',
|
||
left: 0,
|
||
width: 360,
|
||
backgroundColor: '#fff',
|
||
borderRadius: 16,
|
||
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
|
||
padding: 16,
|
||
border: 'none',
|
||
zIndex: 9999,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 选择引擎 */}
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Text style={{
|
||
display: 'block',
|
||
marginBottom: 8,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: '#666666',
|
||
}}>
|
||
选择引擎
|
||
</Text>
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 4,
|
||
}}>
|
||
{(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);
|
||
}
|
||
}
|
||
setShowEngineModal(false);
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 48,
|
||
borderRadius: 8,
|
||
border: countType === engine.id
|
||
? '2px solid #6366f1'
|
||
: '1px solid #e5e7eb',
|
||
backgroundColor: countType === engine.id
|
||
? '#fff'
|
||
: '#f9fafb',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'flex-start',
|
||
padding: '8px 12px',
|
||
transition: 'all 0.2s',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<span style={{
|
||
fontSize: 13,
|
||
fontWeight: countType === engine.id ? 600 : 500,
|
||
color: countType === engine.id ? '#6366f1' : '#4b5563',
|
||
marginBottom: 2,
|
||
}}>
|
||
{engine.name}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 11,
|
||
color: '#9ca3af',
|
||
}}>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 图片设置按钮 */}
|
||
{mediaType === 'image' && (
|
||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||
<button
|
||
onClick={() => setShowImageSettingsModal(!showImageSettingsModal)}
|
||
className="image-settings-trigger"
|
||
style={{
|
||
minWidth: 220,
|
||
padding: '4px 12px',
|
||
height: 34,
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
backgroundColor: '#f1f5f9',
|
||
cursor: 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<LayoutOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||
<Text style={{
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
color: '#64748b',
|
||
}}>
|
||
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution == '2K' ? '2K高清' : '4K超清'} · {width}×{height}
|
||
</Text>
|
||
</button>
|
||
|
||
{showImageSettingsModal && (
|
||
<div
|
||
className="image-settings-popover"
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 'calc(100% + 8px)',
|
||
left: 0,
|
||
width: 480,
|
||
backgroundColor: '#fff',
|
||
borderRadius: 16,
|
||
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
|
||
padding: 16,
|
||
border: 'none',
|
||
zIndex: 9999,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 选择比例 */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Text style={{
|
||
display: 'block',
|
||
marginBottom: 8,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: '#666666',
|
||
}}>
|
||
选择比例
|
||
</Text>
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 4,
|
||
}}>
|
||
{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 #6366f1'
|
||
: '1px solid #e5e7eb',
|
||
backgroundColor: selectedRatio === item.value
|
||
? '#fff'
|
||
: '#f9fafb',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: item.value === 'auto' ? 14 : 18,
|
||
height: item.value === 'auto' ? 14 : 18,
|
||
border: `2px solid ${selectedRatio === item.value ? '#6366f1' : '#9ca3af'}`,
|
||
borderRadius: 2,
|
||
marginBottom: 2,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}>
|
||
{item.value === 'auto' && (
|
||
<span style={{
|
||
fontSize: 7,
|
||
color: selectedRatio === item.value
|
||
? '#6366f1'
|
||
: '#9ca3af',
|
||
}}>
|
||
智
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span style={{
|
||
fontSize: 9,
|
||
color: selectedRatio === item.value
|
||
? '#6366f1'
|
||
: '#6b7280',
|
||
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: '#666666',
|
||
}}>
|
||
选择分辨率
|
||
</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 #6366f1'
|
||
: '1px solid #e5e7eb',
|
||
backgroundColor: selectedResolution === item.value
|
||
? '#6366f1'
|
||
: '#f9fafb',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<span style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: selectedResolution === item.value
|
||
? '#fff'
|
||
: '#4b5563',
|
||
}}>
|
||
{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: '#666666',
|
||
}}>
|
||
尺寸
|
||
</Text>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
}}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
flex: 1,
|
||
maxWidth: 120,
|
||
}}>
|
||
<span style={{
|
||
color: '#9ca3af',
|
||
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 #e5e7eb',
|
||
backgroundColor: '#f9fafb',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: '#1f2937',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSwap}
|
||
style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 4,
|
||
border: '1px solid #e5e7eb',
|
||
backgroundColor: '#fff',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
color: '#6366f1',
|
||
}}
|
||
>
|
||
<SwapOutlined style={{ fontSize: 10 }} />
|
||
</button>
|
||
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
flex: 1,
|
||
maxWidth: 120,
|
||
}}>
|
||
<span style={{
|
||
color: '#9ca3af',
|
||
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 #e5e7eb',
|
||
backgroundColor: '#f9fafb',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: '#1f2937',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<span style={{ color: '#9ca3af', fontSize: 11 }}>
|
||
PX
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 视频参数设置 */}
|
||
{mediaType === 'video' && (
|
||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||
<button
|
||
onClick={() => setShowVideoSettingsModal(!showVideoSettingsModal)}
|
||
className="image-settings-trigger"
|
||
style={{
|
||
minWidth: 220,
|
||
padding: '4px 12px',
|
||
height: 34,
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
backgroundColor: '#f1f5f9',
|
||
cursor: 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<LayoutOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||
<Text style={{
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
color: '#64748b',
|
||
}}>
|
||
{videoAspectRatio} · {videoDuration}s · {videoResolution}
|
||
</Text>
|
||
</button>
|
||
|
||
{showVideoSettingsModal && (
|
||
<div
|
||
className="image-settings-popover"
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 'calc(100% + 8px)',
|
||
left: 0,
|
||
width: 400,
|
||
backgroundColor: '#fff',
|
||
borderRadius: 16,
|
||
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
|
||
padding: 16,
|
||
border: 'none',
|
||
zIndex: 9999,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 选择比例 */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Text style={{
|
||
display: 'block',
|
||
marginBottom: 8,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: '#666666',
|
||
}}>
|
||
选择比例
|
||
</Text>
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 4,
|
||
}}>
|
||
{engineOptions.ratios.map((ratio) => (
|
||
<button
|
||
key={ratio}
|
||
onClick={() => setVideoAspectRatio(ratio)}
|
||
style={{
|
||
flex: '0 0 calc(14.28% - 4px)',
|
||
minWidth: 44,
|
||
height: 52,
|
||
borderRadius: 6,
|
||
border: videoAspectRatio === ratio
|
||
? '2px solid #6366f1'
|
||
: '1px solid #e5e7eb',
|
||
backgroundColor: videoAspectRatio === ratio
|
||
? '#fff'
|
||
: '#f9fafb',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 18,
|
||
height: 18,
|
||
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
|
||
borderRadius: 2,
|
||
marginBottom: 2,
|
||
}} />
|
||
<span style={{
|
||
fontSize: 9,
|
||
color: videoAspectRatio === ratio
|
||
? '#6366f1'
|
||
: '#6b7280',
|
||
fontWeight: videoAspectRatio === ratio ? 600 : 400,
|
||
}}>
|
||
{ratio}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 选择时长 */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Text style={{
|
||
display: 'block',
|
||
marginBottom: 8,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: '#666666',
|
||
}}>
|
||
选择时长
|
||
</Text>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{ flex: 1, position: 'relative', height: 24, display: 'flex', alignItems: 'center' }}>
|
||
{/* 背景轨道 */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: '50%',
|
||
left: 0,
|
||
right: 0,
|
||
height: 6,
|
||
borderRadius: 3,
|
||
background: '#e5e7eb',
|
||
transform: 'translateY(-50%)',
|
||
}} />
|
||
{/* 已滑动部分 */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: '50%',
|
||
left: 0,
|
||
height: 6,
|
||
borderRadius: 3,
|
||
background: '#6366f1',
|
||
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`,
|
||
transform: 'translateY(-50%)',
|
||
}} />
|
||
{/* 滑块 */}
|
||
<input
|
||
type="range"
|
||
min={Math.min(...engineOptions.durations)}
|
||
max={Math.max(...engineOptions.durations)}
|
||
value={videoDuration}
|
||
onChange={(e) => setVideoDuration(Number(e.target.value))}
|
||
style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: 24,
|
||
borderRadius: 3,
|
||
background: 'transparent',
|
||
outline: 'none',
|
||
appearance: 'none',
|
||
cursor: 'pointer',
|
||
zIndex: 1,
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
padding: '4px 12px',
|
||
backgroundColor: '#f1f5f9',
|
||
borderRadius: 6,
|
||
}}>
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: '#64748b' }}>
|
||
{videoDuration}
|
||
</span>
|
||
<span style={{ fontSize: 12, color: '#9ca3af' }}>秒</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 选择分辨率 */}
|
||
<div>
|
||
<Text style={{
|
||
display: 'block',
|
||
marginBottom: 8,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: '#666666',
|
||
}}>
|
||
选择分辨率
|
||
</Text>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
{engineOptions.resolutions.map((resolution) => (
|
||
<button
|
||
key={resolution}
|
||
onClick={() => setVideoResolution(resolution)}
|
||
style={{
|
||
flex: 1,
|
||
height: 42,
|
||
borderRadius: 6,
|
||
border: videoResolution === resolution
|
||
? '2px solid #6366f1'
|
||
: '1px solid #e5e7eb',
|
||
backgroundColor: videoResolution === resolution
|
||
? '#6366f1'
|
||
: '#f9fafb',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<span style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: videoResolution === resolution
|
||
? '#fff'
|
||
: '#4b5563',
|
||
}}>
|
||
{resolution}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* 预估积分 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Text style={{ fontSize: 12, color: '#94a3b8' }}>预估积分</Text>
|
||
<Text style={{ fontSize: 12, color: '#666666' }}>{getEstimatedCredits()}</Text>
|
||
</div>
|
||
</Space>
|
||
|
||
{/* 底部信息 */}
|
||
{/* <div style={{ display: 'flex', gap: 8, fontSize: 12, color: '#999' }}>
|
||
<span>GPT Video 2</span>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<span style={{ width: 16, height: 16, borderRadius: 4, background: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10 }}>
|
||
{mediaType === 'image' ? (selectedRatio === 'auto' ? '智能' : selectedRatio) : videoAspectRatio}
|
||
</span>
|
||
<span>{countType}</span>
|
||
</span>
|
||
</div> */}
|
||
</div>
|
||
</div>
|
||
</Content>
|
||
</Layout>
|
||
|
||
{/* 自定义CSS动画 - 加载中闪烁效果 */}
|
||
<style>{`
|
||
@keyframes blink {
|
||
0%, 50% { opacity: 1; }
|
||
51%, 100% { opacity: 0.3; }
|
||
}
|
||
`}</style>
|
||
|
||
{/* 图片/视频预览弹窗 */}
|
||
<Modal
|
||
open={previewVisible}
|
||
onCancel={handleClosePreview}
|
||
footer={[
|
||
<Button key="download" type="primary" onClick={handleDownload}>
|
||
下载
|
||
</Button>,
|
||
]}
|
||
width={800}
|
||
centered
|
||
closeIcon={
|
||
<button
|
||
onClick={handleClosePreview}
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
border: 'none',
|
||
background: '#ff4d4f',
|
||
cursor: 'pointer',
|
||
fontSize: 14,
|
||
color: '#fff',
|
||
fontWeight: 'bold',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#ff7875';
|
||
e.currentTarget.style.transform = 'scale(1.1)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = '#ff4d4f';
|
||
e.currentTarget.style.transform = 'scale(1)';
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
}
|
||
bodyStyle={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
minHeight: '400px',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||
{isMediaExpired(previewUrl) ? (
|
||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||
{/* <div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div> */}
|
||
<p style={{ fontSize: 16, color: '#ff4d4f', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
|
||
{/* <Button
|
||
type="primary"
|
||
onClick={() => {
|
||
window.location.reload();
|
||
}}
|
||
>
|
||
刷新页面重新加载
|
||
</Button> */}
|
||
</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}
|
||
onCancel={() => setAttachmentPreviewVisible(false)}
|
||
footer={[
|
||
<Button key="download" type="primary" onClick={() => {
|
||
if (!attachmentPreviewUrl) return;
|
||
const link = document.createElement('a');
|
||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`;
|
||
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}}>
|
||
下载
|
||
</Button>,
|
||
]}
|
||
width={800}
|
||
centered
|
||
closeIcon={
|
||
<button
|
||
onClick={() => setAttachmentPreviewVisible(false)}
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
border: 'none',
|
||
background: '#ff4d4f',
|
||
cursor: 'pointer',
|
||
fontSize: 14,
|
||
color: '#fff',
|
||
fontWeight: 'bold',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#ff7875';
|
||
e.currentTarget.style.transform = 'scale(1.1)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = '#ff4d4f';
|
||
e.currentTarget.style.transform = 'scale(1)';
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
}
|
||
bodyStyle={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
minHeight: '400px',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||
{attachmentPreviewType === 'image' ? (
|
||
<img
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||
alt="预览"
|
||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||
/>
|
||
) : (
|
||
<video
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||
controls
|
||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default AIChatPage;
|