生成数量添加

This commit is contained in:
sjy
2026-07-15 17:10:49 +08:00
72 changed files with 4628 additions and 1561 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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;
+129 -63
View File
@@ -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' }}
/>
+4 -12
View File
@@ -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>
+2 -1
View File
@@ -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' && (
+5
View File
@@ -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,
+1
View File
@@ -140,6 +140,7 @@ export interface MediaReference {
displayUrl?: string;
previewUrl?: string;
upload_resource_id?: string;
fileSizeBytes?: number;
}
+23
View File
@@ -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;
}
}