生成数量添加
This commit is contained in:
+557
File diff suppressed because one or more lines are too long
-557
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-Dq-lhFsY.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DUztRbKf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons';
|
||||
|
||||
export interface GenerationTaskResourceItem {
|
||||
id?: string;
|
||||
genType?: string;
|
||||
status?: string;
|
||||
displayStatus?: string | null;
|
||||
pipelineStage?: string | null;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
videoCoverUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
generationIndex?: number | null;
|
||||
}
|
||||
|
||||
export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem {
|
||||
generationCount?: number | null;
|
||||
childItems?: GenerationTaskResourceItem[] | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
task: GenerationTaskResourceGroup;
|
||||
onPreview: (url: string, type: 'image' | 'video') => void;
|
||||
resolveUrl?: (url?: string | null) => string;
|
||||
}
|
||||
|
||||
const spanByCount = (count: number, index: number): number => {
|
||||
if (count <= 1) return 6;
|
||||
if (count === 2 || count === 4) return 3;
|
||||
if (count === 3) return 3;
|
||||
return 2;
|
||||
};
|
||||
|
||||
const statusText = (item: GenerationTaskResourceItem): string => {
|
||||
const status = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
||||
const labels: Record<string, string> = {
|
||||
pending: '待处理', queued: '排队中', preparing: '准备中', generating: '生成中',
|
||||
creating_provider_task: '创建任务中', waiting_remote: '等待生成', polling: '轮询中',
|
||||
result_ready: '结果就绪', download_queued: '等待下载', downloading: '下载中',
|
||||
retry_waiting: '等待重试', completed: '已完成', failed: '生成失败',
|
||||
download_failed: '下载失败', deleted: '已删除',
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const isPending = (item: GenerationTaskResourceItem): boolean => {
|
||||
const status = item.displayStatus || item.pipelineStage || item.status;
|
||||
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
|
||||
};
|
||||
|
||||
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveUrl = (url) => url || '' }) => {
|
||||
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
|
||||
const children = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
|
||||
const items: GenerationTaskResourceItem[] = children.length > 0
|
||||
? children
|
||||
: (count > 1 ? Array.from({ length: count }, (_, index) => ({
|
||||
id: `${task.id || 'task'}-placeholder-${index + 1}`,
|
||||
genType: task.genType,
|
||||
status: task.status,
|
||||
displayStatus: task.displayStatus,
|
||||
pipelineStage: task.pipelineStage,
|
||||
generationIndex: index + 1,
|
||||
errorMessage: task.errorMessage,
|
||||
})) : [task]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}>
|
||||
{items.slice(0, 5).map((item, index) => {
|
||||
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
||||
const imageUrl = resolveUrl(`/static${item.imageUrl}&w=300&q=50`);
|
||||
const videoUrl = resolveUrl(item.videoUrl);
|
||||
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
|
||||
const isVideo = (item.genType || task.genType) === 'video';
|
||||
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
|
||||
return (
|
||||
<div
|
||||
key={item.id || `${index}`}
|
||||
style={{
|
||||
gridColumn: `span ${spanByCount(items.length, index)}`,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: items.length === 1 ? 12 : 8,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #E7EAF0',
|
||||
}}
|
||||
>
|
||||
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && !isPending(item) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
|
||||
style={{ width: '100%', height: '100%', border: 0, padding: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
{isVideo ? (
|
||||
coverUrl ? <img src={coverUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
: <video src={videoUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<img src={imageUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
)}
|
||||
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', fontSize: items.length > 2 ? 28 : 46, color: 'rgba(255,255,255,.92)', filter: 'drop-shadow(0 4px 10px rgba(0,0,0,.28))' }} /> : null}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 7, padding: 8, textAlign: 'center' }}>
|
||||
{isPending(item) ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />}
|
||||
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: isPending(item) ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusText(item)}</span>
|
||||
{!isPending(item) && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{items.length > 1 ? <span style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, padding: '1px 6px', borderRadius: 10, background: 'rgba(17,24,39,.58)', color: '#fff', fontSize: 10 }}>#{item.generationIndex || index + 1}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerationTaskResourceGrid;
|
||||
@@ -24,6 +24,7 @@ import bg3 from '../assets/bg3.png';
|
||||
import text from '../assets/testb.png';
|
||||
|
||||
import UploadSelector from '../components/UploadSelector';
|
||||
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
PauseOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
import { div } from 'three/tsl';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
// 解构Input组件
|
||||
@@ -68,6 +70,17 @@ const { Option } = Select;
|
||||
// 解构Typography组件
|
||||
const { Text } = Typography;
|
||||
|
||||
const GENERATION_RESOURCE_BASE = (import.meta.env.VITE_API_BASE || 'http://localhost:8000')
|
||||
.replace(/\/api\/?$/i, '')
|
||||
.replace(/\/$/, '');
|
||||
const resolveGenerationResourceUrl = (url?: string | null): string => {
|
||||
if (!url) return '';
|
||||
const value = String(url).trim();
|
||||
if (!value) return '';
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
|
||||
return `${GENERATION_RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
};
|
||||
|
||||
|
||||
interface MediaReference {
|
||||
name: string;
|
||||
@@ -80,6 +93,7 @@ interface MediaReference {
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
upload_resource_id?: string;
|
||||
fileSizeBytes?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +111,7 @@ interface Message {
|
||||
resolution?: string;
|
||||
timestamp?: string;
|
||||
engine_id: string;
|
||||
generation_count: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +153,7 @@ const AIChatPage: React.FC = () => {
|
||||
const {
|
||||
mediaType,
|
||||
countType,
|
||||
generationCount,
|
||||
selectedRatio,
|
||||
selectedResolution,
|
||||
width,
|
||||
@@ -150,6 +166,7 @@ const AIChatPage: React.FC = () => {
|
||||
inputValue,
|
||||
setMediaType,
|
||||
setCountType,
|
||||
setGenerationCount,
|
||||
setSelectedRatio,
|
||||
setSelectedResolution,
|
||||
setWidth,
|
||||
@@ -170,6 +187,24 @@ const AIChatPage: React.FC = () => {
|
||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
||||
const maxImageCount = currentEngine?.maxImageCount ?? 4;
|
||||
const maxVideoCount = currentEngine?.maxVideoCount ?? 1;
|
||||
const multiGenerationEnabled = Boolean(currentEngine?.multiGenerationEnabled);
|
||||
const configuredMaxGenerationCount = multiGenerationEnabled
|
||||
? Math.max(1, Math.min(5, Number(currentEngine?.maxGenerationCount || 1)))
|
||||
: 1;
|
||||
const referenceImageCount = mediaType === 'image'
|
||||
? currentMedia.filter((item) => item.type === 'image').length
|
||||
: 0;
|
||||
const imageProviderRemainingCount = mediaType === 'image'
|
||||
? Math.max(1, Number(currentEngine?.multiImageMaxImages || 15) - referenceImageCount)
|
||||
: 5;
|
||||
const effectiveMaxGenerationCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
5,
|
||||
configuredMaxGenerationCount,
|
||||
mediaType === 'image' ? imageProviderRemainingCount : 5,
|
||||
),
|
||||
);
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
||||
@@ -444,7 +479,7 @@ const AIChatPage: React.FC = () => {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
}
|
||||
return Number(total.toFixed(2));
|
||||
return Number((total * generationCount).toFixed(2));
|
||||
} else {
|
||||
// 图片:baseCredits × ratio
|
||||
let total = config.baseCredits * config.ratio;
|
||||
@@ -456,7 +491,7 @@ const AIChatPage: React.FC = () => {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
}
|
||||
return Number(total.toFixed(2));
|
||||
return Number((total * generationCount).toFixed(2));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -558,6 +593,22 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}, [mediaType, enginesele, enginesLoaded]);
|
||||
|
||||
// 切换媒体类型或引擎时,默认回到最安全的单份生成。
|
||||
useEffect(() => {
|
||||
if (!enginesLoaded) return;
|
||||
setGenerationCount(1);
|
||||
}, [mediaType, countType, enginesLoaded, setGenerationCount]);
|
||||
|
||||
// 图片参考图数量变化后动态收敛本次可选数量;后端仍会再次校验。
|
||||
useEffect(() => {
|
||||
if (generationCount > effectiveMaxGenerationCount) {
|
||||
setGenerationCount(effectiveMaxGenerationCount);
|
||||
if (mediaType === 'image') {
|
||||
antdMessage.info(`受当前引擎或参考图数量限制,本次最多生成 ${effectiveMaxGenerationCount} 份`);
|
||||
}
|
||||
}
|
||||
}, [generationCount, effectiveMaxGenerationCount, mediaType, setGenerationCount, antdMessage]);
|
||||
|
||||
// 点击外部关闭弹窗
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -703,8 +754,9 @@ const AIChatPage: React.FC = () => {
|
||||
setCreditCalculationData(data);
|
||||
})
|
||||
getgen_list(Pagebreak).then((data: any) => {
|
||||
let mess_list = data.items
|
||||
let total = data.total
|
||||
// API 按创建时间倒序返回;对话区按时间正序展示,最新消息保持在底部。
|
||||
const mess_list = data.items
|
||||
const total = data.total
|
||||
setGen_list(mess_list)
|
||||
setTotalnumber(total)
|
||||
})
|
||||
@@ -1005,6 +1057,7 @@ const AIChatPage: React.FC = () => {
|
||||
engine_id: countType,
|
||||
|
||||
idempotency_key: new Date().toLocaleString('zh-CN'),
|
||||
generation_count: generationCount,
|
||||
media_references: mediaReferences,
|
||||
// 图片参数(仅图片模式时添加)
|
||||
...(mediaType === 'image' && {
|
||||
@@ -1034,6 +1087,7 @@ const AIChatPage: React.FC = () => {
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
setGenerationCount(1);
|
||||
|
||||
// 创建任务成功后,重置页数为1,获取最新列表
|
||||
const newPagebreak = { ...Pagebreak, page: 1 };
|
||||
@@ -1041,7 +1095,12 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
getgen_list(newPagebreak).then((data: any) => {
|
||||
// 将data.items的最后一个元素添加到gen_list末尾
|
||||
setGen_list((prev: any[]) => [...prev, data.items[data.items.length - 1]]);
|
||||
const newestItem = Array.isArray(data.items) && data.items.length > 0
|
||||
? data.items[data.items.length - 1]
|
||||
: null;
|
||||
if (newestItem) {
|
||||
setGen_list((prev: any[]) => [...prev, newestItem]);
|
||||
}
|
||||
setTotalnumber(data.total);
|
||||
// 发送消息后滚动到底部
|
||||
setTimeout(() => {
|
||||
@@ -1121,13 +1180,13 @@ const AIChatPage: React.FC = () => {
|
||||
setGen_list((prev: any[]) => {
|
||||
const existingIds = new Set(prev.map((item: any) => item.id));
|
||||
// 只添加不存在的新数据,保持新数据的原有顺序
|
||||
const newItems = data.items.filter((item: any) => {
|
||||
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;
|
||||
});
|
||||
// 新数据在前,旧数据在后
|
||||
}).reverse();
|
||||
// 加载的是更早一页,按时间正序放到现有消息前面。
|
||||
return [...newItems, ...prev];
|
||||
});
|
||||
|
||||
@@ -1288,8 +1347,9 @@ const AIChatPage: React.FC = () => {
|
||||
setLastFrame(mediaRef);
|
||||
}
|
||||
antdMessage.success('图片上传成功');
|
||||
} catch (error) {
|
||||
antdMessage.error('上传失败');
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadTarget(null);
|
||||
@@ -1437,7 +1497,8 @@ const AIChatPage: React.FC = () => {
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
} catch (error) {
|
||||
antdMessage.error('上传失败');
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -1445,7 +1506,7 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }> => {
|
||||
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number; fileSizeBytes: number }> => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isAudio = file.type.startsWith('audio/');
|
||||
@@ -1455,12 +1516,24 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 30);
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isImage || isVideo) {
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const currentTotalSize = latestMedia
|
||||
.filter((m) => m.type === 'image' || m.type === 'video')
|
||||
.reduce((sum, m) => sum + (Number(m.fileSizeBytes) || 0), 0);
|
||||
const totalSizeMB = (currentTotalSize + file.size) / 1024 / 1024;
|
||||
if (totalSizeMB > 64) {
|
||||
antdMessage.error(`所有图片和视频总大小不能超过64MB,当前已${(currentTotalSize / 1024 / 1024).toFixed(1)}MB,加上此文件后${totalSizeMB.toFixed(1)}MB`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||
@@ -1556,10 +1629,12 @@ const AIChatPage: React.FC = () => {
|
||||
type: pendingMedia.type,
|
||||
url: res.url,
|
||||
label: pendingMedia.label || '',
|
||||
fileSizeBytes: file.size,
|
||||
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
||||
};
|
||||
} catch (error) {
|
||||
antdMessage.error('上传失败');
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1838,12 +1913,18 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
const handleDownload = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!previewUrl) return;
|
||||
let downloadUrl = previewUrl.replace('/static', '').replace(/&w=\d+/i, '').replace(/&q=\d+/i, '');
|
||||
if (!downloadUrl.includes('download=1')) {
|
||||
downloadUrl += '&download=1';
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
|
||||
link.href = downloadUrl;
|
||||
console.log(downloadUrl);
|
||||
|
||||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -2453,50 +2534,16 @@ const AIChatPage: React.FC = () => {
|
||||
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 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)', background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)' }}>
|
||||
<GenerationTaskResourceGrid
|
||||
task={msg}
|
||||
resolveUrl={resolveGenerationResourceUrl}
|
||||
onPreview={(url, type) => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(type);
|
||||
setPreviewVisible(true);
|
||||
}}
|
||||
/>
|
||||
</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' }}>
|
||||
@@ -2506,7 +2553,7 @@ const AIChatPage: React.FC = () => {
|
||||
<span style={{
|
||||
// background: 'rgba(139, 92, 246, 0.08)',
|
||||
borderRadius: 16, color: '#8b5cf6', fontWeight: 500
|
||||
}}>{msg.engineSnapshot.name}</span>
|
||||
}}>{msg.engineSnapshot?.name || '未知引擎'}</span>
|
||||
<span style={{
|
||||
// background: 'rgba(139, 92, 246, 0.08)',
|
||||
borderRadius: 16, color: '#667085'
|
||||
@@ -4418,6 +4465,23 @@ const AIChatPage: React.FC = () => {
|
||||
</Space>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{multiGenerationEnabled && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', height: 34, padding: '0 8px 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>
|
||||
<Select
|
||||
value={generationCount}
|
||||
onChange={(value) => setGenerationCount(Number(value || 1))}
|
||||
disabled={effectiveMaxGenerationCount <= 1}
|
||||
size="small"
|
||||
variant="borderless"
|
||||
style={{ width: 68 }}
|
||||
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
|
||||
value: index + 1,
|
||||
label: `${index + 1}份`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
@@ -4562,15 +4626,17 @@ const AIChatPage: React.FC = () => {
|
||||
<p style={{ fontSize: 16, color: '#A45B5B', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
|
||||
</div>
|
||||
) : previewType === 'image' ? (
|
||||
// <div>{previewUrl}</div>
|
||||
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewUrl}&w=300&q=50`}
|
||||
src={`${previewUrl}`}
|
||||
alt="预览"
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
||||
src={`${previewUrl}`}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { copyToClipboard } from "../utils/clipboard";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Button,
|
||||
@@ -2741,10 +2742,7 @@ const GeneratePage: React.FC = () => {
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(editedPrompt);
|
||||
message.success("已复制");
|
||||
}}
|
||||
onClick={async () => { const ok = await copyToClipboard(editedPrompt); message.success(ok ? '已复制' : '复制失败'); }}
|
||||
style={{ color: "#94a3b8" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -2886,10 +2884,7 @@ const GeneratePage: React.FC = () => {
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(editedPrompt);
|
||||
message.success("已复制");
|
||||
}}
|
||||
onClick={async () => { const ok = await copyToClipboard(editedPrompt); message.success(ok ? '已复制' : '复制失败'); }}
|
||||
style={{ color: "#94a3b8" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -3683,10 +3678,7 @@ const GeneratePage: React.FC = () => {
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
message.success("已复制");
|
||||
}}
|
||||
onClick={async () => { const ok = await copyToClipboard(prompt); message.success(ok ? '已复制' : '复制失败'); }}
|
||||
style={{ color: "#94a3b8" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import type { GenerationStatus, AspectRatio, Resolution } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
|
||||
const statusConfig: Record<GenerationStatus, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
|
||||
@@ -300,7 +301,7 @@ const RecordsPage: React.FC = () => {
|
||||
<Space size={4}>
|
||||
<Tooltip title="复制">
|
||||
<Button type="text" size="small" icon={<CopyOutlined />}
|
||||
onClick={() => { navigator.clipboard.writeText(prompt); message.success('已复制'); }}
|
||||
onClick={async () => { const ok = await copyToClipboard(prompt); message.success(ok ? '已复制' : '复制失败'); }}
|
||||
style={{ color: '#94a3b8' }} />
|
||||
</Tooltip>
|
||||
{record.status === 'prompt_optimized' && (
|
||||
|
||||
@@ -18,6 +18,7 @@ interface AppState {
|
||||
// 生成配置状态 - 页面跳转时保留,刷新时重置
|
||||
mediaType: string;
|
||||
countType: string;
|
||||
generationCount: number;
|
||||
selectedRatio: string;
|
||||
selectedResolution: string;
|
||||
width: number;
|
||||
@@ -46,6 +47,7 @@ interface AppState {
|
||||
// 生成配置状态更新方法
|
||||
setMediaType: (mediaType: string) => void;
|
||||
setCountType: (countType: string) => void;
|
||||
setGenerationCount: (generationCount: number) => void;
|
||||
setImageSettings: (ratio: string, resolution: string, width: number, height: number) => void;
|
||||
setVideoSettings: (duration: number, aspectRatio: string, resolution: string) => void;
|
||||
setEngineOptions: (options: { ratios: string[]; resolutions: string[]; durations: number[] }) => void;
|
||||
@@ -71,6 +73,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
// 生成配置状态初始值
|
||||
mediaType: 'video',
|
||||
countType: '请选择',
|
||||
generationCount: 1,
|
||||
selectedRatio: '1:1',
|
||||
selectedResolution: '2K',
|
||||
width: 2048,
|
||||
@@ -160,6 +163,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
// 生成配置状态更新方法
|
||||
setMediaType: (mediaType) => set({ mediaType }),
|
||||
setCountType: (countType) => set({ countType }),
|
||||
setGenerationCount: (generationCount) => set({ generationCount }),
|
||||
setImageSettings: (ratio, resolution, width, height) =>
|
||||
set({ selectedRatio: ratio, selectedResolution: resolution, width, height }),
|
||||
setVideoSettings: (duration, aspectRatio, resolution) =>
|
||||
@@ -179,6 +183,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
resetGenerationConfig: () => set({
|
||||
mediaType: 'image',
|
||||
countType: '请选择',
|
||||
generationCount: 1,
|
||||
selectedRatio: '1:1',
|
||||
selectedResolution: '2K',
|
||||
width: 2048,
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface MediaReference {
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
upload_resource_id?: string;
|
||||
fileSizeBytes?: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
// 降级方案:使用 textarea + execCommand
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const succeeded = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return succeeded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user