Files
video-gen/video-gen-app/src/pages/GeneratePage.tsx
T
2026-07-16 17:52:49 +08:00

4869 lines
195 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState, useCallback } from "react";
import { copyToClipboard } from "../utils/clipboard";
import { createPortal } from "react-dom";
import {
Button,
Card,
Form,
Input,
Modal,
Radio,
message,
Space,
Tag,
Tooltip,
Typography,
Upload,
Image,
} from "antd";
import {
ArrowLeftOutlined,
PlayCircleOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
LoadingOutlined,
ThunderboltOutlined,
CopyOutlined,
CloseCircleOutlined,
EditOutlined,
RocketOutlined,
CaretRightOutlined,
CaretDownOutlined,
VideoCameraOutlined,
DownloadOutlined,
PlusOutlined,
PictureOutlined,
SwapOutlined,
XOutlined,
} from "@ant-design/icons";
import { useNavigate, useParams } from "react-router-dom";
import { useAppStore } from "../store/useAppStore";
import { useAuthStore } from "../store/useAuthStore";
import type {
GenerationRecord,
AspectRatio,
Resolution,
MediaReference,
OptionGroup,
} from "../types";
import {
getIndustries,
getParameters,
getVideoEngines,
uploadImage,
uploadVideo,
deleteUpload,
updateRecordPrompt,
getCreditRatios,
login,
getRecordsPage,
} from "../api";
import { formatDate } from "../utils/formatDate";
import UploadSelector from "../components/UploadSelector";
import { generateUUID } from "../utils/uuid";
// const calcVideoCredits = (duration: number, resolution: Resolution): number => {
// const configs: Record<
// string,
// { base: number; perSecond: number; ratio: number }
// > = {
// "480p": { base: 50, perSecond: 1, ratio: 0.8 },
// "720p": { base: 80, perSecond: 2, ratio: 1.0 },
// "1080p": { base: 120, perSecond: 3, ratio: 1.5 },
// };
// const cfg = configs[resolution] || configs["720p"];
// return Math.round((cfg.base + cfg.perSecond * duration) * cfg.ratio);
// };
/** Video thumbnail: captures a frame at 1s and shows duration */
const VideoThumb: React.FC<{
src: string;
width?: number;
height?: number;
}> = ({ src, width = 48, height = 48 }) => {
const [thumbnail, setThumbnail] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(null);
useEffect(() => {
const video = document.createElement("video");
video.muted = true;
video.preload = "metadata";
video.crossOrigin = "anonymous";
const onMeta = () => {
setDuration(video.duration);
video.currentTime = Math.min(1, video.duration * 0.1);
};
const onSeeked = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
setThumbnail(canvas.toDataURL("image/jpeg", 0.7));
}
} catch {
/* CORS */
}
};
video.addEventListener("loadedmetadata", onMeta);
video.addEventListener("seeked", onSeeked);
video.src = src;
return () => {
video.removeEventListener("loadedmetadata", onMeta);
video.removeEventListener("seeked", onSeeked);
};
}, [src, width, height]);
const fmt = (s: number) => `${Math.round(s)}s`;
return (
<div
style={{
width: "100%",
height: "100%",
position: "relative",
background: "#1a1a2e",
}}
>
{thumbnail ? (
<img
src={thumbnail}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<PlayCircleOutlined style={{ color: "#fff", fontSize: 18 }} />
</div>
)}
{duration != null && (
<span
style={{
position: "absolute",
bottom: 2,
right: 2,
fontSize: 9,
color: "#fff",
background: "rgba(0,0,0,0.6)",
borderRadius: 4,
padding: "1px 4px",
lineHeight: 1.3,
}}
>
{fmt(duration)}
</span>
)}
</div>
);
};
const GeneratePage: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [form] = Form.useForm();
const {
projects,
records,
fetchProjects,
fetchRecords,
optimizePrompt,
generateVideo,
} = useAppStore();
const recordItems = records.items;
const { user } = useAuthStore();
const [optimizing, setOptimizing] = useState(false);
const [showOptimized, setShowOptimized] = useState(false);
const [currentRecord, setCurrentRecord] = useState<GenerationRecord | null>(
null,
);
const [editedPrompt, setEditedPrompt] = useState("");
const [isEditingOptimized, setIsEditingOptimized] = useState(false);
const [generating, setGenerating] = useState<Record<string, boolean>>({});
const [recordStates, setRecordStates] = useState<
Record<string, "idle" | "generating" | "done" | "failed">
>({});
const [generationProgress, setGenerationProgress] = useState<Record<string, number>>({});
const [generationDisplayProgress, setGenerationDisplayProgress] = useState<Record<string, number>>({});
const [generationFinishing, setGenerationFinishing] = useState<Record<string, boolean>>({});
const generationIntervalRef = useRef<number | null>(null);
const MAX_DURATION_SECONDS = 180;
const PROGRESS_RATE = 0.6;
const calculateProgressValue = useCallback((createdAt?: string): number => {
if (!createdAt) return 0;
const createdTime = new Date(createdAt).getTime();
const now = Date.now();
const elapsedSeconds = (now - createdTime) / 1000;
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
return 99;
}
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
}, []);
useEffect(() => {
const generatingIds = Object.entries(recordStates)
.filter(([, state]) => state === "generating")
.map(([id]) => id);
if (generatingIds.length === 0) {
if (generationIntervalRef.current) {
clearInterval(generationIntervalRef.current);
generationIntervalRef.current = null;
}
return;
}
const updateProgress = () => {
generatingIds.forEach((id) => {
const record = useAppStore.getState().records.items.find((r: any) => r.id === id);
if (record?.createdAt) {
const newProgress = calculateProgressValue(record.createdAt);
setGenerationProgress((prev) => ({ ...prev, [id]: newProgress }));
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
}
});
};
updateProgress();
generationIntervalRef.current = window.setInterval(updateProgress, 1000);
return () => {
if (generationIntervalRef.current) {
clearInterval(generationIntervalRef.current);
generationIntervalRef.current = null;
}
};
}, [recordStates, calculateProgressValue]);
useEffect(() => {
Object.entries(recordStates).forEach(([id, state]) => {
if (state === "done" && !generationFinishing[id]) {
setGenerationFinishing((prev) => ({ ...prev, [id]: true }));
const currentProgress = generationDisplayProgress[id] || 0;
const targetProgress = 100;
const duration = 800;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
});
}, [recordStates]);
useEffect(() => {
Object.entries(recordStates).forEach(([id, state]) => {
if (state === "generating") {
const record = useAppStore.getState().records.items.find((r: any) => r.id === id);
if (record?.createdAt) {
const newProgress = calculateProgressValue(record.createdAt);
setGenerationProgress((prev) => ({ ...prev, [id]: newProgress }));
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
}
}
});
}, [currentRecord]);
const [editablePrompts, setEditablePrompts] = useState<
Record<string, string>
>({});
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
const [expandedHistoryId, setExpandedHistoryId] = useState<string | null>(
null,
);
const [references, setReferences] = useState<MediaReference[]>([]);
const [uploading, setUploading] = useState(false);
const [promptText, setPromptText] = useState("");
const [showMention, setShowMention] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
const promptRef = useRef<any>(null);
const [industryLabels, setIndustryLabels] = useState<Record<string, string>>(
{},
);
const [industryOptionGroups, setIndustryOptionGroups] = useState<
Record<string, OptionGroup[]>
>({});
const [selectedOptions, setSelectedOptions] = useState<
Record<string, string>
>({});
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
// Per-record param selections for history prompt_optimized records
const [historyParams, setHistoryParams] = useState<
Record<string, { aspectRatio: AspectRatio; resolution: Resolution }>
>({});
// Video preview modal
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
// Video params for step 2
const [videoDuration, setVideoDuration] = useState(5);
const [videoAspectRatio, setVideoAspectRatio] = useState<AspectRatio>("16:9");
const [videoResolution, setVideoResolution] = useState<Resolution>("720p");
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [engineOptions, setEngineOptions] = useState<{
ratios: string[];
resolutions: string[];
durations: number[];
}>({
ratios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
resolutions: ["720p", "1080p"],
durations: [5, 8, 10, 12, 15],
});
// Track last text credits for display
const [lastTextCredits, setLastTextCredits] = useState(0);
const [lastTextTokens, setLastTextTokens] = useState(0);
// Media type: 1 for image, 2 for video
const [mediaType, setMediaType] = useState<any>("video");
// Image parameters
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 [creditRatios, setCreditRatios] = useState<any>([]);
const [cimage, setCimage] = useState<any>([]);
const validateImageDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
return null;
};
const validateVideoDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
const totalPixels = width * height;
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
return null;
};
const handlePasteUpload = 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;
}
const imageCount = references.filter(
(r) => r.type === "image",
).length;
const videoCount = references.filter(
(r) => r.type === "video",
).length;
const videoDuration = references
.filter((r) => r.type === "video")
.reduce((sum, r) => sum + (r.duration || 0), 0);
const MAX_IMAGES = 5;
const MAX_VIDEOS = 2;
const MAX_VIDEO_DURATION = 15;
if (isImage && imageCount >= MAX_IMAGES) {
message.error(`最多上传${MAX_IMAGES}张图片`);
return false;
}
if (isVideo && videoCount >= MAX_VIDEOS) {
message.error(`最多上传${MAX_VIDEOS}个视频`);
return false;
}
let fileDuration = 0;
if (isVideo) {
const videoInfo = await new Promise<{ duration: number; width: number; height: number }>((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve({ duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 });
video.remove();
};
video.onerror = () => {
resolve({ duration: 0, width: 0, height: 0 });
video.remove();
};
video.src = URL.createObjectURL(file);
});
fileDuration = videoInfo.duration;
if (videoInfo.width > 0 && videoInfo.height > 0) {
const error = validateVideoDimensions(videoInfo.width, videoInfo.height);
if (error) {
message.error(`${error}`);
return false;
}
}
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}秒`);
return false;
}
} else {
const imageInfo = await new Promise<{ width: number; height: number }>((resolve) => {
const img = document.createElement('img');
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
if (imageInfo.width > 0 && imageInfo.height > 0) {
const error = validateImageDimensions(imageInfo.width, imageInfo.height);
if (error) {
message.error(`${error}`);
return false;
}
}
}
setUploading(true);
try {
let res;
if (isImage) {
res = await uploadImage(file);
} else {
res = await uploadVideo(file, fileDuration);
}
const typeLabel = isImage ? "图片" : "视频";
const typeCount = isImage
? imageCount + 1
: videoCount + 1;
setReferences((prev) => [
...prev,
{
url: res.url,
type: isImage ? "image" : "video",
name: `${typeLabel}${typeCount}`,
duration: isVideo ? fileDuration : undefined,
},
]);
message.success(`${typeLabel}上传成功`);
} catch {
message.error("上传失败");
} finally {
setUploading(false);
}
return false;
};
// 点击空白处关闭图片设置浮层
useEffect(() => {
getCreditRatios().then((data: any) => {
// console.log('123123', data);
setCreditRatios(data.video);
setCimage(data.image);
// console.log('creditRatios (直接使用data.values):', data.values);
})
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]);
const calcVideoCredits = (duration: number, resolution: Resolution): any => {
// console.log(duration,resolution);
for (let i = 0; i < creditRatios.length; i++) {
const ratio = creditRatios[i];
if (ratio.resolution === resolution) {
const cfg = ratio;
return Math.round((cfg.baseCredits + cfg.perSecondCredits * duration) * cfg.ratio);
}
}
// if (condition) {
// }
// return Math.round((cfg.base + cfg.perSecond * duration) * cfg.ratio);
};
// 根据图片分辨率获取积分
const getImageCredits = (imageSize: string): any => {
// 将 imageSize 转换为 creditRatios 中的 resolution 格式
const resolution = imageSize === '2k' ? '2048' : imageSize === '4k' ? '4096' : imageSize;
for (let i = 0; i < cimage.length; i++) {
const ratio = cimage[i];
if (ratio.resolution === resolution) {
return ratio.baseCredits;
}
}
return 0;
};
const calculateSizeFromRatione = (ratio: string) => {
setBlindex(ratio);
const size = supportedSizes[selectedResolution]?.[ratio];
if (size) {
const [w, h] = size.split(/x/i).map(Number);
setWidth(w);
setHeight(h);
}
};
const calculateSizeFromRatiotwo = (resolution: string) => {
setFBlindex(resolution);
const resolutionRatios = Object.keys(supportedSizes[resolution] || {});
const ratioOpts = resolutionRatios.map((key) => ({
value: key,
label: key,
}));
setRatioOptions(ratioOpts);
const newRatio = resolutionRatios.includes(selectedRatio) ? selectedRatio : resolutionRatios[0] || "1:1";
if (newRatio !== selectedRatio) {
setSelectedRatio(newRatio);
}
const size = supportedSizes[resolution]?.[newRatio];
if (size) {
const [w, h] = size.split(/x/i).map(Number);
setWidth(w);
setHeight(h);
}
};
// 图片比例选项
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 [supportedSizes, setSupportedSizes] = useState<Record<string, Record<string, string>>>({});
// 监听 blindex 状态变化(解决异步问题)
useEffect(() => {
// blindex 更新后执行的逻辑
// 安全检查:确保数组存在后再访问
if (
widthandheight[fblindex] &&
widthandheight[fblindex][blindex] !== undefined
) {
setWidth(widthandheight[fblindex][blindex].substring(0, 4));
setHeight(widthandheight[fblindex][blindex].substring(5));
} else {
}
// 这里可以添加 blindex 更新后需要执行的其他操作
}, [blindex]);
useEffect(() => {
// fblindex 更新后执行的逻辑
// 安全检查:确保数组存在后再访问
if (
widthandheight[fblindex] &&
widthandheight[fblindex][blindex] !== undefined
) {
setWidth(widthandheight[fblindex][blindex].substring(0, 4));
setHeight(widthandheight[fblindex][blindex].substring(5));
} else {
}
// 这里可以添加 fblindex 更新后需要执行的其他操作
}, [fblindex]);
// 获取图片比例选项
// { value: '2k', label: '高清 2K' },
// { value: '4k', label: '超清 4K' },
// 交换宽高
const handleSwap = () => {
setWidth(height);
setHeight(width);
};
// 处理媒体类型选择变化
const handleMediaTypeChange = (e: any) => {
// console.log(e.target.value);
// 获取选中的值('image' 或 'video'
const selectedValue = e.target.value;
// 转换为数字:image→1video→2
const numericValue = selectedValue === "image" ? "image" : "video";
// 输出到控制台
// console.log(numericValue);
// 更新状态(使用转换后的数字值)
setMediaType(numericValue);
};
useEffect(() => {
getIndustries()
.then((data) => {
setIndustryLabels(
Object.fromEntries(data.map((d) => [d.key, d.label])),
);
setIndustryOptionGroups(
Object.fromEntries(data.map((d) => [d.key, d.optionGroups || []])),
);
})
.catch(() => { });
getParameters()
.then((data) => {
const sizes = (data as any).items?.[0]?.supportedSizes || {};
setSupportedSizes(sizes);
const resolutionKeys = Object.keys(sizes);
const resolutionOpts = resolutionKeys.map((key) => ({
value: key,
label: key,
}));
setResolutionOptions(resolutionOpts);
const initResolution = resolutionKeys.includes(selectedResolution) ? selectedResolution : resolutionKeys[0] || "2K";
const ratioKeys = Object.keys(sizes[initResolution] || {});
const ratioOpts = ratioKeys.map((key) => ({
value: key,
label: key,
}));
setRatioOptions(ratioOpts);
const initRatio = ratioKeys.includes(selectedRatio) ? selectedRatio : ratioKeys[0] || "1:1";
if (initRatio !== selectedRatio) {
setSelectedRatio(initRatio);
}
const initSize = sizes[initResolution]?.[initRatio];
if (initSize) {
const [w, h] = initSize.split(/x/i).map(Number);
setWidth(w);
setHeight(h);
}
})
.catch(() => { });
getVideoEngines()
.then((data) => {
if (data.items?.length) {
const e = data.items[0];
setEngineOptions({
ratios: e.supportedRatios?.length
? e.supportedRatios
: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
resolutions: e.supportedResolutions?.length
? e.supportedResolutions
: ["720p", "1080p"],
durations: e.supportedDurations?.length
? e.supportedDurations
: [5, 8, 10, 12, 15],
});
}
})
.catch(() => { });
}, []);
useEffect(() => {
fetchProjects();
fetchRecords({ projectId, page: 1, pageSize: 100 });
// console.log(fetchRecords({ projectId, page: 1, pageSize: 100 }));
}, [projectId, fetchProjects, fetchRecords]);
// Auto-poll: if any records are still generating/optimizing after page load, start polling
const autoPollStartedRef = useRef(false);
useEffect(() => {
if (autoPollStartedRef.current || recordItems.length === 0) return;
autoPollStartedRef.current = true;
recordItems.forEach((r) => {
if (r.projectId === projectId && r.status === "generating") {
setRecordStates((p) => ({ ...p, [r.id]: "generating" }));
startPolling(r.id);
}
if (r.projectId === projectId && r.status === "optimizing") {
// Poll until optimizing completes
startOptimizingPoll(r.id);
}
});
}, [recordItems, projectId]);
// Optimizing 任务轮询(使用独立的轮询集合)
const optimizingRecordIds = useRef<Set<string>>(new Set());
const optimizingPollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const optimizingPendingRequest = useRef<AbortController | null>(null);
/**
* 添加 optimizing 任务到轮询队列
*/
const addToOptimizingPolling = (recordId: string) => {
optimizingRecordIds.current.add(recordId);
startOptimizingGlobalPolling();
};
/**
* 从 optimizing 轮询队列中移除任务
*/
const removeFromOptimizingPolling = (recordId: string) => {
optimizingRecordIds.current.delete(recordId);
if (optimizingRecordIds.current.size === 0) {
stopOptimizingPolling();
}
};
/**
* 停止 optimizing 轮询
*/
const stopOptimizingPolling = () => {
if (optimizingPollTimer.current) {
clearInterval(optimizingPollTimer.current);
optimizingPollTimer.current = null;
}
if (optimizingPendingRequest.current) {
optimizingPendingRequest.current.abort();
optimizingPendingRequest.current = null;
}
};
/**
* 启动 optimizing 全局轮询(合并多个任务的轮询为一个请求)
*/
const startOptimizingGlobalPolling = () => {
if (optimizingPollTimer.current) return;
optimizingPollTimer.current = setInterval(async () => {
try {
if (optimizingPendingRequest.current) {
optimizingPendingRequest.current.abort();
}
const controller = new AbortController();
optimizingPendingRequest.current = controller;
const allRecords = await getRecordsPage({
projectId,
page: 1,
pageSize: 100,
signal: controller.signal
});
optimizingPendingRequest.current = null;
const currentPollingIds = Array.from(optimizingRecordIds.current);
const completedIds: string[] = [];
if (currentPollingIds.length > 0) {
const currentRecords = useAppStore.getState().records;
const updatedItems = currentRecords.items.map((item: any) => {
if (currentPollingIds.includes(item.id)) {
const latest = allRecords.items.find((r: any) => r.id === item.id);
if (latest) {
if (latest.status !== "optimizing") {
completedIds.push(item.id);
}
return latest;
}
}
return item;
});
useAppStore.setState({
records: {
...currentRecords,
items: updatedItems,
},
});
completedIds.forEach((recordId) => {
removeFromOptimizingPolling(recordId);
});
}
} catch (error: any) {
if (error.name !== 'AbortError') {
}
}
}, 3000);
};
/**
* 启动 optimizing 任务轮询
*/
const startOptimizingPoll = (recordId: string) => {
addToOptimizingPolling(recordId);
};
// Recovery: if page was refreshed during optimize, find existing record instead of re-calling API
const recoveredRef = useRef(false);
const inFlightOptimizeKey = useRef<string | null>(null);
useEffect(() => {
if (recoveredRef.current || recordItems.length === 0) return;
const pending = localStorage.getItem("pending_optimize");
if (!pending) return;
try {
const { key, projectId: pId, prompt, duration, ts } = JSON.parse(pending);
if (Date.now() - ts > 5 * 60 * 1000 || pId !== projectId) {
localStorage.removeItem("pending_optimize");
return;
}
// Skip if this is the current in-flight optimize (not a recovery)
if (inFlightOptimizeKey.current === key) return;
// Find matching record in already-loaded records
const match = recordItems.find(
(r) =>
r.projectId === pId &&
r.originalPrompt === prompt &&
r.duration === duration &&
r.status === "prompt_optimized" &&
new Date(r.createdAt).getTime() > ts - 10000,
);
if (match) {
recoveredRef.current = true;
localStorage.removeItem("pending_optimize");
setCurrentRecord(match);
setEditedPrompt(match.optimizedPrompt || "");
setLastTextCredits(match.textCreditsCost);
setLastTextTokens(match.textTokensUsed);
setShowOptimized(true);
message.success({
content: `优化请求已恢复,消耗 ${match.textCreditsCost} 积分`,
key: "recovery",
});
} else if (Date.now() - ts > 30000) {
// No match found after 30s — original request likely failed
localStorage.removeItem("pending_optimize");
}
} catch {
localStorage.removeItem("pending_optimize");
}
}, [recordItems, projectId]);
const project = projects.find((p) => p.id === projectId);
const projectRecords = recordItems.filter((r) => r.projectId === projectId);
const projectName = project?.name ?? "项目";
const currentOptionGroups = project
? industryOptionGroups[project.industry] || []
: [];
const userCredits = user?.credits ?? 0;
// 根据图片分辨率从 cimage 获取积分
const getImageCreditsFromCimage = (imageSize: string): number => {
// 将 imageSize 转换为 cimage 中的 resolution 格式
const resolution = imageSize === '2k' ? '2048' : imageSize === '4k' ? '4096' : imageSize;
for (let i = 0; i < cimage.length; i++) {
const item = cimage[i];
if (item.resolution === resolution) {
return item.baseCredits;
}
}
return 0;
};
// Media credits estimate for step 2 (video or image)
const estimatedVideoCredits = mediaType === "image"
? getImageCreditsFromCimage(selectedResolution)
: calcVideoCredits(videoDuration, videoResolution);
const canAffordVideo = userCredits >= estimatedVideoCredits;
// Step 1: Optimize prompt (text credits)
const handleOptimize = async () => {
try {
if (!projectId) {
message.error("项目ID为空,请先选择项目");
return;
}
const values = await form.validateFields();
if (!values.prompt || !values.prompt.trim()) {
message.error("请输入视频/图片描述");
return;
}
setOptimizing(true);
const optionEntries = Object.entries(selectedOptions).map(
([k, v]) => `@${k}${v}`,
);
const fullPrompt =
optionEntries.length > 0
? `${values.prompt}\n\n${optionEntries.join("")}`
: values.prompt;
// Generate idempotency key for dedup on refresh
const idempotencyKey = generateUUID();
localStorage.setItem(
"pending_optimize",
JSON.stringify({
key: idempotencyKey,
projectId,
prompt: fullPrompt,
duration: videoDuration,
ts: Date.now(),
}),
);
inFlightOptimizeKey.current = idempotencyKey;
const result = await optimizePrompt(projectId, {
prompt: fullPrompt,
duration: videoDuration,
genType: mediaType,
resolution: selectedResolution,
references: references.length > 0 ? references : undefined,
idempotencyKey,
image_size: selectedResolution,
image_proportion: selectedRatio,
image_px: width + "x" + height,
});
// console.log("按钮触发", result);
// Clear in-flight flag so recovery effect doesn't fire during normal flow
localStorage.removeItem("pending_optimize");
inFlightOptimizeKey.current = null;
setCurrentRecord(result.record);
setEditedPrompt(result.record.optimizedPrompt || "");
setLastTextCredits(result.textCreditsCost);
setLastTextTokens(result.textTokensUsed);
setShowOptimized(true);
message.success({
content: `「${projectName}」提示词优化完成,消耗 ${result.textCreditsCost} 积分`,
key: "optimize",
});
} catch (error: any) {
const errorMsg =
error?.response?.data?.message ||
error?.message ||
"操作失败,请稍后重试";
message.error(errorMsg);
} finally {
setOptimizing(false);
localStorage.removeItem("pending_optimize");
inFlightOptimizeKey.current = null;
}
};
// Step 2: Generate video (video credits)
/**
* 轮询定时器管理对象
* 类型说明:Record<string, ReturnType<typeof setInterval>>
* - key: recordId(任务ID
* - value: setInterval 返回的定时器ID
*
* 使用 useRef 的原因:
* 1. 定时器ID需要在组件生命周期中持久保存
* 2. 避免闭包捕获过时的状态值
* 3. 支持动态增删,不受 React 渲染周期影响
*/
// 合并轮询:管理所有需要轮询的任务ID,只启动一个定时器
const pollingRecordIds = useRef<Set<string>>(new Set());
const globalPollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const pendingRequest = useRef<AbortController | null>(null);
/**
* 添加任务到轮询队列
*/
const addToPolling = (recordId: string) => {
pollingRecordIds.current.add(recordId);
startGlobalPolling();
};
/**
* 从轮询队列中移除任务
*/
const removeFromPolling = (recordId: string) => {
pollingRecordIds.current.delete(recordId);
// 如果没有需要轮询的任务了,停止轮询
if (pollingRecordIds.current.size === 0) {
stopGlobalPolling();
}
};
/**
* 停止全局轮询
*/
const stopGlobalPolling = () => {
if (globalPollTimer.current) {
clearInterval(globalPollTimer.current);
globalPollTimer.current = null;
}
if (pendingRequest.current) {
pendingRequest.current.abort();
pendingRequest.current = null;
}
};
/**
* 启动全局轮询(合并多个任务的轮询为一个请求)
*/
const startGlobalPolling = () => {
// 如果已经在轮询中,直接返回
if (globalPollTimer.current) return;
globalPollTimer.current = setInterval(async () => {
try {
// 取消前一个未完成的请求
if (pendingRequest.current) {
pendingRequest.current.abort();
}
// 创建新的请求控制器
const controller = new AbortController();
pendingRequest.current = controller;
// 获取所有记录(单次请求)
const allRecords = await getRecordsPage({
projectId,
page: 1,
pageSize: 100,
signal: controller.signal
});
// 请求完成后清理控制器
pendingRequest.current = null;
// 获取当前需要轮询的任务ID列表
const currentPollingIds = Array.from(pollingRecordIds.current);
// 标记哪些任务已经完成
const completedIds: string[] = [];
// 批量更新所有轮询中的任务状态
if (currentPollingIds.length > 0) {
const currentRecords = useAppStore.getState().records;
const updatedItems = currentRecords.items.map((item: any) => {
// 如果是正在轮询的任务,检查是否有更新
if (currentPollingIds.includes(item.id)) {
const latest = allRecords.items.find((r: any) => r.id === item.id);
if (latest) {
const frontendState = recordStates[item.id];
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
// 不管前端当前是什么状态,都要处理完成的任务
if (latest.status !== "generating") {
completedIds.push(item.id);
} else if (frontendState !== "generating") {
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
setRecordStates((p) => ({ ...p, [item.id]: "generating" }));
}
return latest;
}
}
return item;
});
// 更新全局状态
useAppStore.setState({
records: {
...currentRecords,
items: updatedItems,
},
});
// 处理已完成的任务
completedIds.forEach((recordId) => {
const latest = allRecords.items.find((r: any) => r.id === recordId);
removeFromPolling(recordId);
if (latest?.status === "completed") {
setRecordStates((p) => ({ ...p, [recordId]: "done" }));
setCurrentRecord((prev) => (prev?.id === recordId ? latest : prev));
message.success({
content: `「${projectName}」视频生成完成!`,
key: recordId,
duration: 5,
});
} else if (latest?.status === "failed") {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
setCurrentRecord((prev) => (prev?.id === recordId ? latest : prev));
message.error({
content: `「${projectName}」视频生成失败: ${latest.errorMessage || "未知错误"}`,
key: recordId,
duration: 5,
});
}
});
}
} catch (error: any) {
// 忽略AbortError(正常取消行为)
if (error.name !== 'AbortError') {
}
}
}, 5000); // 轮询间隔:5秒
};
/**
* 启动单个任务的轮询(已改为使用合并轮询)
*/
const startPolling = (recordId: string) => {
addToPolling(recordId);
};
/**
* 组件卸载时清理所有轮询定时器
*
* 使用空依赖数组 [] 的原因:
* - 只在组件挂载时执行一次
* - 返回的清理函数在组件卸载时执行
*/
useEffect(() => {
return () => {
// 停止所有轮询
stopGlobalPolling();
stopOptimizingPolling();
};
}, []);
useEffect(() => {
const generatingRecords = recordItems.filter((r) => r.status === "generating");
generatingRecords.forEach((record) => {
setRecordStates((p) => ({ ...p, [record.id]: "generating" }));
startPolling(record.id);
});
}, [recordItems]);
const handleGenerate = async (recordId: string) => {
if (!canAffordVideo) {
message.error("积分不足,请先充值");
return;
}
setGenerating((p) => ({ ...p, [recordId]: true }));
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
message.loading({
content: `「${projectName}」正在提交视频生成...`,
duration: 0,
key: recordId,
});
try {
const result = await generateVideo(recordId, {
aspectRatio: videoAspectRatio,
resolution: videoResolution,
});
if (result.status === "failed") {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
message.error({
content: `「${projectName}」提交失败`,
key: recordId,
duration: 3,
});
} else {
message.success({
content: `「${projectName}」已提交,正在生成中...`,
key: recordId,
duration: 3,
});
startPolling(recordId);
}
} catch (error: any) {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
// 尝试从错误对象中提取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}`,
key: recordId,
duration: 3,
});
} finally {
setGenerating((p) => ({ ...p, [recordId]: false }));
}
};
const handleRetryGeneration = async (recordId: string) => {
const record = projectRecords.find((r) => r.id === recordId);
if (!record) return;
setGenerating((p) => ({ ...p, [recordId]: true }));
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
setGenerationProgress((p) => ({ ...p, [recordId]: 0 }));
setGenerationDisplayProgress((p) => ({ ...p, [recordId]: 0 }));
setGenerationFinishing((p) => ({ ...p, [recordId]: false }));
message.loading({
content: `「${projectName}」正在重新提交...`,
duration: 0,
key: recordId,
});
try {
// Save edited prompt first if changed
const editedPrompt = editablePrompts[recordId];
if (editedPrompt && editedPrompt !== record.optimizedPrompt) {
await updateRecordPrompt(recordId, editedPrompt);
}
const result = await generateVideo(recordId, {
aspectRatio: record.aspectRatio || "16:9",
resolution: record.resolution || "720p",
});
if (result.status === "failed") {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
message.error({
content: `「${projectName}」提交失败`,
key: recordId,
duration: 3,
});
} else {
message.success({
content: `「${projectName}」已提交,正在生成中...`,
key: recordId,
duration: 3,
});
startPolling(recordId);
}
} catch (error: any) {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
// 尝试从错误对象中提取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}`,
key: recordId,
duration: 3,
});
} finally {
setGenerating((p) => ({ ...p, [recordId]: false }));
}
};
const getRecordStatus = (record: GenerationRecord) => {
// console.log('返回的数据:', record);
const state = recordStates[record.id];
if (state === "generating") return "generating";
if (state === "done") return "completed";
if (state === "failed") return "failed";
return record.status;
};
const statusConfig: Record<
string,
{ color: string; text: string; icon: React.ReactNode }
> = {
optimizing: {
color: "processing",
text: "优化中",
icon: (
<LoadingOutlined style={{ animation: "spinSlow 1s linear infinite" }} />
),
},
prompt_optimized: {
color: "processing",
text: "待生成",
icon: <ClockCircleOutlined />,
},
generating: {
color: "warning",
text: "生成中",
icon: (
<LoadingOutlined style={{ animation: "spinSlow 1s linear infinite" }} />
),
},
completed: {
color: "success",
text: "已完成",
icon: <CheckCircleOutlined />,
},
failed: { color: "error", text: "失败", icon: <CloseCircleOutlined /> },
};
// Portal-based dropdown that renders menu at document.body level (above trigger)
const PortalDropdown: React.FC<{
label: string;
value: string;
options: string[];
suffix?: string;
expanded: boolean;
onToggle: () => void;
onSelect: (val: string) => void;
onClose: () => void;
}> = ({
label,
value,
options,
suffix,
expanded,
onToggle,
onSelect,
onClose,
}) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef(expanded);
const [pos, setPos] = useState<{
bottom: number;
left: number;
width: number;
} | null>(null);
expandedRef.current = expanded;
useEffect(() => {
if (expanded && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({
bottom: window.innerHeight - rect.top + 4,
left: rect.left,
width: Math.max(rect.width, 100),
});
} else {
setPos(null);
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node)) return;
if (menuRef.current?.contains(e.target as Node)) return;
if (expandedRef.current) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded, onClose]);
return (
<>
<div
ref={triggerRef}
onClick={onToggle}
translate="no"
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
cursor: "pointer",
userSelect: "none",
transition: "background 0.2s, border-color 0.2s",
background: expanded ? "rgba(99,102,241,0.08)" : "#f8f9fc",
border: expanded ? "1px solid #6366f1" : "1px solid #e2e8f0",
minWidth: 70,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
<span
style={{
fontSize: 12,
color: "#6366f1",
fontWeight: 500,
lineHeight: "18px",
}}
>
{value}
{suffix}
</span>
<CaretDownOutlined
style={{
fontSize: 9,
color: "#94a3b8",
transition: "transform 0.2s",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
{pos &&
createPortal(
<div
ref={menuRef}
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
minWidth: pos.width,
background: "#fff",
borderRadius: 12,
padding: 6,
zIndex: 99999,
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 10,
color: "#94a3b8",
display: "block",
padding: "2px 6px 4px",
}}
>
{label}
</Typography.Text>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{options.map((opt, oi) => {
const isSelected = value === opt;
return (
<div
key={oi}
onClick={() => {
onSelect(opt);
onClose();
}}
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 13,
cursor: "pointer",
transition: "all 0.15s",
background: isSelected
? "linear-gradient(135deg, #6366f1, #8b5cf6)"
: "transparent",
color: isSelected ? "#fff" : "#1a1a2e",
fontWeight: isSelected ? 600 : 400,
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background = "#f1f5f9";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "transparent";
}}
>
{opt}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
};
// Portal-based option group dropdown for industry options (above trigger)
const PortalOptionGroup: React.FC<{
group: OptionGroup;
selected: string | undefined;
expanded: boolean;
onToggle: () => void;
onSelect: (opt: string) => void;
onClose: () => void;
}> = ({ group, selected, expanded, onToggle, onSelect, onClose }) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef(expanded);
const [pos, setPos] = useState<{ bottom: number; left: number } | null>(
null,
);
expandedRef.current = expanded;
useEffect(() => {
if (expanded && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ bottom: window.innerHeight - rect.top + 4, left: rect.left });
} else {
setPos(null);
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node)) return;
if (menuRef.current?.contains(e.target as Node)) return;
if (expandedRef.current) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded, onClose]);
return (
<>
<div
ref={triggerRef}
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
cursor: "pointer",
userSelect: "none",
transition: "all 0.2s",
background: selected ? "rgba(99,102,241,0.08)" : "#f8f9fc",
border: expanded ? "1px solid #6366f1" : "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 12,
color: selected ? "#6366f1" : "#64748b",
fontWeight: selected ? 600 : 400,
}}
>
{selected || group.name}
</Typography.Text>
<CaretDownOutlined
style={{
fontSize: 9,
color: selected ? "#6366f1" : "#94a3b8",
transition: "transform 0.2s",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
{pos &&
createPortal(
<div
ref={menuRef}
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
background: "#fff",
borderRadius: 12,
padding: 8,
minWidth: 160,
zIndex: 99999,
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 10,
color: "#94a3b8",
display: "block",
padding: "2px 6px 4px",
}}
>
{group.name}
</Typography.Text>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{group.options.map((opt, oi) => {
const isSelected = selected === opt;
return (
<div
key={oi}
onClick={() => {
onSelect(opt);
onClose();
}}
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 13,
cursor: "pointer",
transition: "all 0.15s",
background: isSelected
? "linear-gradient(135deg, #6366f1, #8b5cf6)"
: "transparent",
color: isSelected ? "#fff" : "#1a1a2e",
fontWeight: isSelected ? 600 : 400,
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background = "#f1f5f9";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "transparent";
}}
>
{opt}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
};
return (
<div
style={{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
padding:'24px',
background: '#fff',
overflowY: 'auto'
}}>
{/* Header */}
<div
className="gen-header animate-fadeInUp"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
padding: "16px 24px",
borderRadius: 16,
background: "rgba(255,255,255,0.6)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(99, 102, 241, 0.08)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/projects")}
style={{
borderRadius: 10,
fontSize: 13,
height: 32,
background: "rgba(99, 102, 241, 0.1)",
border: "1px solid rgba(99, 102, 241, 0.2)",
color: "#6366f1",
}}
>
返回
</Button>
<div style={{ width: 32, height: 2, background: "linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)", borderRadius: 1 }} />
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Typography.Title
level={4}
style={{ margin: 0, fontSize: 16, fontWeight: 700, background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", backgroundClip: "text" }}
>
{projectName}
</Typography.Title>
{project && (
<Tag
style={{
background: "rgba(99, 102, 241, 0.08)",
border: "1px solid rgba(99, 102, 241, 0.15)",
color: "#6366f1",
borderRadius: 6,
}}
>
{industryLabels[project.industry] || project.industry}
</Tag>
)}
</div>
<Typography.Text
style={{ color: "#64748b", fontSize: 13 }}
>
描述视频 AI优化 生成视频
</Typography.Text>
</div>
<div style={{ width: 32, height: 2, background: "linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)", borderRadius: 1 }} />
</div>
<Tag
style={{
background: "rgba(99, 102, 241, 0.08)",
border: "1px solid rgba(99, 102, 241, 0.15)",
color: "#6366f1",
borderRadius: 20,
fontSize: 13,
padding: "4px 14px",
}}
>
剩余 {userCredits} 积分
</Tag>
</div>
{/* ========== STEP 1: Describe video ========== */}
{!showOptimized && (
<>
{/* Flow steps */}
<div
className="animate-fadeInUp"
style={{
marginBottom: 24,
padding: "16px 20px",
borderRadius: 16,
background: "#fff",
border: "1px solid #f0f0f5",
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
}}
>
<div
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
}}
>
{/* 媒体类型选择区域 */}
<div
style={{
marginRight: 70,
position: "relative",
}}
>
{/* 标签文字 */}
<Typography.Text
style={{
fontSize: 14,
fontWeight: 500,
color: "#475569",
marginRight: 16,
}}
>
生成类型:
</Typography.Text>
{/* 单选按钮组 */}
<Radio.Group
defaultValue="video"
buttonStyle="solid"
onChange={handleMediaTypeChange}
>
{/* 视频选项 */}
<Radio.Button value="video">
<VideoCameraOutlined
style={{ marginRight: 6, fontSize: 14 }}
/>{" "}
{/* 视频图标 */}
视频
</Radio.Button>
{/* 图片选项 */}
<Radio.Button value="image" style={{ zIndex: 0 }}>
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />{" "}
{/* 图片图标 */}
图片
</Radio.Button>
</Radio.Group>
</div>
{mediaType === "video" &&
[
{
icon: <EditOutlined />,
label: "描述视频+时长",
color: "#6366f1",
},
{
icon: <ThunderboltOutlined />,
label: "AI智能优化",
color: "#8b5cf6",
},
{
icon: <RocketOutlined />,
label: "选择参数生成",
color: "#10b981",
},
].map((step, i) => (
<React.Fragment key={i}>
{i > 0 && (
<div
style={{
width: 40,
height: 1,
background:
"linear-gradient(90deg, #e2e8f0, #cbd5e1, #e2e8f0)",
margin: "0 12px",
flexShrink: 0,
}}
/>
)}
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 10,
background: `${step.color}12`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: step.color, fontSize: 15 }}>
{step.icon}
</span>
</div>
<Typography.Text
style={{
fontSize: 13,
fontWeight: 500,
color: "#1a1a2e",
}}
>
{step.label}
</Typography.Text>
</div>
</React.Fragment>
))}
{mediaType === "image" &&
[
{
icon: <EditOutlined />,
label: "描述图片+尺寸",
color: "#6366f1",
},
{
icon: <ThunderboltOutlined />,
label: "AI智能优化",
color: "#8b5cf6",
},
{
icon: <RocketOutlined />,
label: "图片生成",
color: "#10b981",
},
].map((step, i) => (
<React.Fragment key={i}>
{i > 0 && (
<div
style={{
width: 40,
height: 1,
background:
"linear-gradient(90deg, #e2e8f0, #cbd5e1, #e2e8f0)",
margin: "0 12px",
flexShrink: 0,
}}
/>
)}
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 10,
background: `${step.color}12`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: step.color, fontSize: 15 }}>
{step.icon}
</span>
</div>
<Typography.Text
style={{
fontSize: 13,
fontWeight: 500,
color: "#1a1a2e",
}}
>
{step.label}
</Typography.Text>
</div>
</React.Fragment>
))}
</div>
</div>
{/* Input form */}
<Form form={form} initialValues={{}}>
<Form.Item
name="prompt"
noStyle
rules={[{ required: true, message: "请输入视频描述" }]}
style={{ display: "none" }}
/>
<div
className="animate-fadeInUp"
style={{
borderRadius: 20,
background: "#fff",
border: "1px solid #f0f0f5",
boxShadow: "0 4px 24px rgba(0,0,0,0.04)",
overflow: "visible",
}}
>
{/* Reference uploads */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 16px 0",
minHeight: 60,
}}
>
{references.map((ref, i) => (
<div
key={i}
className="ref-thumb"
style={{
position: "relative",
width: 48,
height: 48,
borderRadius: 12,
overflow: "visible",
flexShrink: 0,
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
overflow: "hidden",
border: "1px solid #e2e8f0",
cursor: "pointer",
}}
onClick={() => {
if (ref.type === "video") {
setPreviewVideoUrl(
`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`,
);
}
}}
>
{ref.type === "image" ? (
<Image
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<VideoThumb
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
/>
)}
</div>
{/* Delete button - shows on hover, physically deletes file */}
<div
className="ref-delete"
onClick={(e) => {
e.stopPropagation();
deleteUpload(ref.url).catch(() => { });
setReferences((prev) => prev.filter((_, j) => j !== i));
}}
style={{
position: "absolute",
top: -4,
right: -4,
width: 16,
height: 16,
borderRadius: "50%",
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 10,
opacity: 0,
transition: "opacity 0.15s",
backdropFilter: "blur(4px)",
}}
>
<CloseCircleOutlined
style={{ color: "#fff", fontSize: 10 }}
/>
</div>
</div>
))}
<UploadSelector
accept="image/*,video/*"
onLocalSelect={(files) => {
files.forEach(async (file) => {
await handlePasteUpload(file);
});
}}
onHistorySelect={(items) => {
items.forEach((item) => {
setReferences(prev => [...prev, {
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
type: item.resourceType,
name: item.fileName || '',
}]);
});
message.success(`成功添加${items.length}个历史记录`);
}}
onPortraitSelect={(items) => {
items.forEach((item: any) => {
setReferences(prev => [...prev, {
url: item.previewUrl || '',
type: item.assetType === 'Video' ? 'video' : 'image',
name: item.name || '真人素材',
source: 'private_portrait_asset',
private_asset_id: item.id,
label: '',
duration: item.assetType === 'Video' ? (item.videoDuration || 0) : undefined,
}]);
});
message.success(`已添加 ${items.length} 个真人素材参考`);
}}
uploading={uploading}
tooltipTitle={`图片${references.filter((r) => r.type === 'image').length}/5,视频${references.filter((r) => r.type === 'video').length}/2`}
maxImageCount={5}
maxVideoCount={2}
usedImageCount={references.filter((r) => r.type === 'image').length}
usedVideoCount={references.filter((r) => r.type === 'video').length}
usedVideoDuration={references.filter((r) => r.type === 'video').reduce((sum, r) => sum + (r.duration || 0), 0)}
maxVideoDuration={15}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
border: "1.5px dashed #d9d9d9",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s",
flexShrink: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.background =
"rgba(99,102,241,0.04)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d9d9d9";
e.currentTarget.style.background = "transparent";
}}
>
{uploading ? (
<LoadingOutlined
style={{ fontSize: 18, color: "#6366f1" }}
/>
) : (
<PlusOutlined
style={{ fontSize: 18, color: "#94a3b8" }}
/>
)}
</div>
</UploadSelector>
</div>
{/* Textarea */}
<div style={{ padding: "8px 16px 4px" }}>
<Input.TextArea
ref={promptRef}
value={promptText}
onChange={(e) => {
const val = e.target.value;
setPromptText(val);
form.setFieldsValue({ prompt: val });
const lastAt = val.lastIndexOf("@");
if (lastAt >= 0) {
const afterAt = val.slice(lastAt + 1);
if (!afterAt.includes(" ") && !afterAt.includes("\n")) {
setShowMention(true);
setMentionFilter(afterAt);
return;
}
}
setShowMention(false);
}}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const file = items[i].getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
imageFiles.forEach(async (file) => {
await handlePasteUpload(file);
});
}
}}
rows={3}
placeholder="上传参考素材(只用做模型理解,不参与生成)、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
maxLength={500}
bordered={false}
autoSize={{ minRows: 2, maxRows: 6 }}
style={{
padding: "6px 2px",
fontSize: 15,
lineHeight: 1.7,
resize: "none",
caretColor: "#6366f1",
}}
/>
</div>
{/* @ mention dropdown */}
{showMention && references.length > 0 && (
<div
style={{
margin: "0 16px 8px",
background: "#fff",
borderRadius: 10,
boxShadow: "0 6px 24px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
padding: 6,
maxHeight: 180,
overflowY: "auto",
}}
>
{references
.filter(
(r) => !mentionFilter || r.name.includes(mentionFilter),
)
.map((ref, i) => (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 8,
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "#f1f5f9")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "transparent")
}
onMouseDown={(e) => {
e.preventDefault();
const textarea =
(promptRef.current as any)?.resizableTextArea
?.textArea ||
promptRef.current?.nativeElement?.querySelector(
"textarea",
) ||
promptRef.current;
if (!textarea || !textarea.value) return;
const val = textarea.value;
const lastAt = val.lastIndexOf("@");
const newVal = val.slice(0, lastAt) + `@${ref.name} `;
setPromptText(newVal);
form.setFieldValue("prompt", newVal);
setShowMention(false);
setTimeout(() => {
textarea.focus();
textarea.selectionStart = newVal.length;
textarea.selectionEnd = newVal.length;
}, 50);
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
overflow: "hidden",
background:
ref.type === "image" ? "#f1f5f9" : "#1a1a2e",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{ref.type === "image" ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<PlayCircleOutlined
style={{ color: "#fff", fontSize: 14 }}
/>
)}
</div>
<Typography.Text style={{ fontSize: 13 }}>
{ref.name}
</Typography.Text>
<Tag style={{ marginLeft: "auto", fontSize: 11 }}>
{ref.type === "image" ? "图片" : "视频"}
</Tag>
</div>
))}
</div>
)}
{/* Toolbar: duration + industry options + optimize button */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px 12px",
gap: 8,
flexWrap: "wrap",
position: "relative",
zIndex: 50,
minHeight: 48,
}}
>
{/* 媒体类型为视频(mediaType=2)时显示时长选择器和行业选项 */}
{mediaType !== "image" && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flex: 1,
flexWrap: "wrap",
}}
>
{/* Duration selector */}
<PortalDropdown
label="时长"
value={`${videoDuration}`}
options={engineOptions.durations.map((d) => `${d}`)}
suffix="秒"
expanded={expandedEngine === "duration"}
onToggle={() =>
setExpandedEngine(
expandedEngine === "duration" ? null : "duration",
)
}
onSelect={(v) => setVideoDuration(parseInt(v))}
onClose={() => setExpandedEngine(null)}
/>
{currentOptionGroups.map((group, gi) => {
const isExpanded = expandedGroup === group.name;
const selected = selectedOptions[group.name];
return (
<PortalOptionGroup
key={`og-${gi}`}
group={group}
selected={selected}
expanded={isExpanded}
onToggle={() =>
setExpandedGroup(isExpanded ? null : group.name)
}
onSelect={(opt) => {
setSelectedOptions((prev) => {
const next = { ...prev };
if (next[group.name] === opt) {
delete next[group.name];
} else {
next[group.name] = opt;
}
return next;
});
}}
onClose={() => setExpandedGroup(null)}
/>
);
})}
</div>
)}
{mediaType == "image" && (
<div
style={{ position: "relative", display: "inline-block" }}
>
<button
onClick={() =>
setShowImageSettingsModal(!showImageSettingsModal)
}
className="image-settings-trigger"
style={{
minWidth: 220,
padding: "0px 16px",
height: 30,
borderRadius: 10,
border: "1px solid #e2e8f0",
backgroundColor: "#f8f9fc",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
transition: "all 0.2s",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{/* 比例图标 */}
<div style={{ textAlign: "left" }}>
<Typography.Text
style={{
fontSize: 13,
fontWeight: 600,
color: "#374151",
marginRight: 10,
}}
>
{selectedRatio === "auto" ? "智能" : selectedRatio}
</Typography.Text>
<Typography.Text
style={{ fontSize: 12, color: "#9ca3af" }}
>
{selectedResolution === "1K" ? "标清 1K" : selectedResolution === "2K" ? "高清 2K" : `${selectedResolution}分辨率`}
| {width}×{height}
</Typography.Text>
</div>
</div>
<CaretDownOutlined
style={{ fontSize: 14, color: "#9ca3af" }}
/>
</button>
{showImageSettingsModal && (
<div
className="image-settings-popover"
style={{
position: "absolute",
bottom: "calc(100% + 8px)",
left: 0,
width: 520,
backgroundColor: "#fff",
borderRadius: 16,
boxShadow: "0 10px 40px rgba(0,0,0,0.15)",
padding: 20,
border: "none",
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择比例 */}
<div style={{ marginBottom: 20 }}>
<Typography.Text
style={{
display: "block",
marginBottom: 10,
fontSize: 13,
fontWeight: 500,
color: "#666666",
}}
>
选择比例
</Typography.Text>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
}}
>
{ratioOptions.map((item) => (
<button
key={item.value}
onClick={() => {
setSelectedRatio(item.value);
calculateSizeFromRatione(item.label);
}}
style={{
flex: "0 0 calc(11.11% - 5px)",
minWidth: 48,
height: 56,
borderRadius: 8,
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: (() => {
const [w, h] = item.value === "auto" ? [1, 1] : item.value.split(":").map(Number);
const maxSize = 20;
if (w >= h) return maxSize;
return Math.round(maxSize * (w / h));
})(),
height: (() => {
const [w, h] = item.value === "auto" ? [1, 1] : item.value.split(":").map(Number);
const maxSize = 20;
if (h >= w) return maxSize;
return Math.round(maxSize * (h / w));
})(),
border: `2px solid ${selectedRatio === item.value ? "#6366f1" : "#9ca3af"}`,
borderRadius: item.value === "auto" ? 3 : 3,
marginBottom: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{item.value === "auto" && (
<span
style={{
fontSize: 8,
color:
selectedRatio === item.value
? "#6366f1"
: "#9ca3af",
}}
>
</span>
)}
</div>
<span
style={{
fontSize: 10,
color:
selectedRatio === item.value
? "#6366f1"
: "#6b7280",
fontWeight:
selectedRatio === item.value ? 600 : 400,
}}
>
{item.value}
</span>
</button>
))}
</div>
</div>
{/* 选择分辨率 */}
<div style={{ marginBottom: 20 }}>
<Typography.Text
style={{
display: "block",
marginBottom: 10,
fontSize: 13,
fontWeight: 500,
color: "#666666",
}}
>
选择分辨率
</Typography.Text>
<div style={{ display: "flex", gap: 8 }}>
{resolutionOptions.map((item) => (
<button
key={item.value}
onClick={() => {
setSelectedResolution(item.value);
calculateSizeFromRatiotwo(item.label);
}}
style={{
flex: 1,
height: 48,
borderRadius: 8,
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: 13,
fontWeight: 600,
color:
selectedResolution === item.value
? "#fff"
: "#4b5563",
}}
>
{item.value}
{item.value === "4k" && (
<span style={{ marginLeft: 3 }}></span>
)}
</span>
</button>
))}
</div>
</div>
{/* 尺寸 */}
<div>
<Typography.Text
style={{
display: "block",
marginBottom: 10,
fontSize: 13,
fontWeight: 500,
color: "#666666",
}}
>
尺寸
</Typography.Text>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
flex: 1,
maxWidth: 140,
}}
>
<span
style={{
color: "#9ca3af",
fontSize: 12,
marginRight: 6,
}}
>
W
</span>
<Input
value={width}
onChange={(e) =>
setWidth(Number(e.target.value) || 0)
}
style={{
flex: 1,
textAlign: "center",
borderRadius: 6,
height: 40,
border: "1px solid #e5e7eb",
backgroundColor: "#f9fafb",
fontSize: 14,
fontWeight: 600,
color: "#1f2937",
}}
/>
</div>
<button
onClick={handleSwap}
style={{
width: 28,
height: 28,
borderRadius: 6,
border: "1px solid #e5e7eb",
backgroundColor: "#fff",
cursor: "pointer",
display: "flex",
justifyContent: "center",
alignItems: "center",
color: "#6366f1",
}}
>
<SwapOutlined style={{ fontSize: 12 }} />
</button>
<div
style={{
display: "flex",
alignItems: "center",
flex: 1,
maxWidth: 140,
}}
>
<span
style={{
color: "#9ca3af",
fontSize: 12,
marginRight: 6,
}}
>
H
</span>
<Input
value={height}
onChange={(e) =>
setHeight(Number(e.target.value) || 0)
}
style={{
flex: 1,
textAlign: "center",
borderRadius: 6,
height: 40,
border: "1px solid #e5e7eb",
backgroundColor: "#f9fafb",
fontSize: 14,
fontWeight: 600,
color: "#1f2937",
}}
/>
</div>
<span style={{ color: "#9ca3af", fontSize: 12 }}>
PX
</span>
</div>
</div>
</div>
)}
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}
>
{mediaType === "image" && (
<span style={{ color: "#7c7c7c", fontSize: 14 }}>
(请在左侧选择图片比例和尺寸)
</span>
)}
<Button
type="primary"
size="large"
onClick={handleOptimize}
loading={optimizing}
style={{
borderRadius: 10,
fontWeight: 600,
minWidth: 140,
height: 40,
background:
"linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
border: "none",
boxShadow: "0 4px 16px rgba(99,102,241,0.35)",
}}
>
AI优化提示词
</Button>
</div>
</div>
</div>
</Form>
</>
)}
{/* ========== STEP 2: Review + Generate ========== */}
{showOptimized && currentRecord && (
<Card
className="animate-fadeInUp"
style={{ borderRadius: 16 }}
styles={{ body: { padding: "24px 28px" } }}
>
{/* Text credits info - only show when not generating or done */}
{!recordStates[currentRecord.id] || recordStates[currentRecord.id] === "idle" ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginBottom: 24,
padding: "14px 18px",
borderRadius: 12,
background:
"linear-gradient(135deg, rgba(16,185,129,0.06), rgba(5,150,105,0.06))",
border: "1px solid rgba(16,185,129,0.15)",
}}
>
<CheckCircleOutlined style={{ color: "#10b981", fontSize: 22 }} />
<div>
<Typography.Text
strong
style={{ fontSize: 15, display: "block" }}
>
提示词优化完成
</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: "#94a3b8" }}>
文本优化消耗 {lastTextCredits} 积分·
您可以编辑提示词后选择参数生成视频
</Typography.Text>
</div>
</div>
) : (
/* Generation state */
<div
style={{
marginBottom: 24,
padding: "16px 18px",
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 12,
background:
recordStates[currentRecord.id] === "failed"
? "rgba(239,68,68,0.04)"
: recordStates[currentRecord.id] === "done"
? "rgba(16,185,129,0.04)"
: "rgba(99,102,241,0.04)",
border: `1px solid ${recordStates[currentRecord.id] === "failed" ? "rgba(239,68,68,0.12)" : recordStates[currentRecord.id] === "done" ? "rgba(16,185,129,0.12)" : "rgba(99,102,241,0.12)"}`,
}}
>
{recordStates[currentRecord.id] === "generating" && (
<LoadingOutlined
style={{ color: "#6366f1", fontSize: 18 }}
spin
/>
)}
{recordStates[currentRecord.id] === "done" && (
<CheckCircleOutlined
style={{ color: "#10b981", fontSize: 18 }}
/>
)}
{recordStates[currentRecord.id] === "failed" && (
<CloseCircleOutlined
style={{ color: "#ef4444", fontSize: 18 }}
/>
)}
<Typography.Text
strong
style={{
fontSize: 14,
color:
recordStates[currentRecord.id] === "failed"
? "#ef4444"
: recordStates[currentRecord.id] === "done"
? "#10b981"
: "#6366f1",
}}
>
{recordStates[currentRecord.id] === "generating" && (
<span>
{mediaType === "image" ? "图片" : "视频"}生成中,请稍候...
<span
style={{
marginLeft: 8,
fontSize: 14,
fontWeight: 700,
color: "#6366f1",
textShadow: "0 0 8px rgba(99, 102, 241, 0.3)",
}}
>
{Math.round(generationDisplayProgress[currentRecord.id] || 0)}%
</span>
</span>
)}
{recordStates[currentRecord.id] === "done" &&
`${mediaType === "image" ? "图片" : "视频"}生成成功`}
{recordStates[currentRecord.id] === "failed" &&
`${mediaType === "image" ? "图片" : "视频"}生成失败,请重试`}
</Typography.Text>
</div>
)}
{/* Prompts comparison */}
{!recordStates[currentRecord.id] || recordStates[currentRecord.id] === "idle" || recordStates[currentRecord.id] === "failed" ? (
// Before generation or failed - side by side layout
<div
className="gen-prompt-row"
style={{ display: "flex", gap: 20, flexWrap: "wrap" }}
>
<div style={{ flex: "1 1 45%", minWidth: 240 }}>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 8,
}}
>
原始提示词
</Typography.Text>
<div
style={{
padding: 14,
borderRadius: 12,
background: "#f8f9fc",
border: "1px solid #f0f0f5",
minHeight: 100,
}}
>
<Typography.Text
style={{ fontSize: 14, color: "#64748b", lineHeight: 1.7 }}
>
{currentRecord.originalPrompt}
</Typography.Text>
</div>
</div>
<div style={{ flex: "1 1 45%", minWidth: 240 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
}}
>
<Typography.Text style={{ fontSize: 12, color: "#94a3b8" }}>
优化后的提示词{" "}
{!isEditingOptimized && (
<span style={{ color: "#6366f1" }}>(可编辑)</span>
)}
</Typography.Text>
{!isEditingOptimized && (
<Space size={4}>
<Tooltip title="复制">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={async () => { const ok = await copyToClipboard(editedPrompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: "#94a3b8" }}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => setIsEditingOptimized(true)}
style={{ color: "#6366f1" }}
/>
</Tooltip>
</Space>
)}
</div>
{isEditingOptimized ? (
<div>
<Input.TextArea
value={editedPrompt}
onChange={(e) => setEditedPrompt(e.target.value)}
rows={5}
style={{
borderRadius: 12,
fontSize: 14,
lineHeight: 1.7,
border: "1px solid rgba(99,102,241,0.25)",
background: "rgba(99,102,241,0.02)",
}}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button
size="small"
onClick={() => {
setEditedPrompt(currentRecord.optimizedPrompt || "");
setIsEditingOptimized(false);
}}
>
取消
</Button>
<Button
size="small"
type="primary"
onClick={() => {
setIsEditingOptimized(false);
message.success("提示词已更新");
}}
style={{
borderRadius: 6,
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
border: "none",
}}
>
保存
</Button>
</div>
</div>
) : (
<div
style={{
padding: 14,
borderRadius: 12,
background: "rgba(99,102,241,0.02)",
border: "1px solid rgba(99,102,241,0.1)",
minHeight: 100,
}}
>
<Typography.Text
style={{ fontSize: 14, color: "#1a1a2e", lineHeight: 1.7 }}
>
{editedPrompt}
</Typography.Text>
</div>
)}
</div>
</div>
) : (
// After generation - prompts stacked on left, image/video on right
<div
className="gen-prompt-row"
style={{ display: "flex", gap: 24, flexWrap: "wrap" }}
>
{/* Prompts stacked on left */}
<div style={{ flex: "1 1 55%", minWidth: 280 }}>
{/* Original prompt */}
<div style={{ marginBottom: 16 }}>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 8,
}}
>
原始提示词
</Typography.Text>
<div
style={{
padding: 14,
borderRadius: 12,
background: "#f8f9fc",
border: "1px solid #f0f0f5",
minHeight: 80,
}}
>
<Typography.Text
style={{ fontSize: 13, color: "#64748b", lineHeight: 1.6 }}
>
{currentRecord.originalPrompt}
</Typography.Text>
</div>
</div>
{/* Optimized prompt below original */}
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
}}
>
<Typography.Text style={{ fontSize: 12, color: "#94a3b8" }}>
优化后的提示词{" "}
{!isEditingOptimized && (
<span style={{ color: "#6366f1" }}>(可编辑)</span>
)}
</Typography.Text>
{!isEditingOptimized && (
<Space size={4}>
<Tooltip title="复制">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={async () => { const ok = await copyToClipboard(editedPrompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: "#94a3b8" }}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => setIsEditingOptimized(true)}
style={{ color: "#6366f1" }}
/>
</Tooltip>
</Space>
)}
</div>
{isEditingOptimized ? (
<div>
<Input.TextArea
value={editedPrompt}
onChange={(e) => setEditedPrompt(e.target.value)}
rows={5}
style={{
borderRadius: 12,
fontSize: 13,
lineHeight: 1.6,
border: "1px solid rgba(99,102,241,0.25)",
background: "rgba(99,102,241,0.02)",
}}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button
size="small"
onClick={() => {
setEditedPrompt(currentRecord.optimizedPrompt || "");
setIsEditingOptimized(false);
}}
>
取消
</Button>
<Button
size="small"
type="primary"
onClick={() => {
setIsEditingOptimized(false);
message.success("提示词已更新");
}}
style={{
borderRadius: 6,
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
border: "none",
}}
>
保存
</Button>
</div>
</div>
) : (
<div
style={{
padding: 14,
borderRadius: 12,
background: "rgba(99,102,241,0.02)",
border: "1px solid rgba(99,102,241,0.1)",
minHeight: 120,
}}
>
<Typography.Text
style={{ fontSize: 13, color: "#1a1a2e", lineHeight: 1.6 }}
>
{editedPrompt}
</Typography.Text>
</div>
)}
</div>
</div>
{/* Media on right */}
<div style={{ flex: "1 1 40%", minWidth: 280 }}>
{/* Video */}
{currentRecord.videoUrl && (
<div>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 8,
}}
>
生成视频
</Typography.Text>
<div
style={{
borderRadius: 12,
overflow: "hidden",
border: "1px solid #f0f0f5",
background: "#000",
}}
>
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${currentRecord.videoUrl}`}
controls
style={{ width: "100%", maxHeight: 350, display: "block" }}
/>
</div>
</div>
)}
{/* Image */}
{currentRecord.imageUrl && (
<div>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 8,
}}
>
生成图片
</Typography.Text>
<Image
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${currentRecord.imageUrl}`}
style={{ width: "100%", maxHeight: 350, objectFit: "contain", borderRadius: 12, border: "1px solid #f0f0f5" }}
preview
/>
</div>
)}
</div>
</div>
)}
{/* Video params selection */}
{/* {mediaType !== 'image' && (
<div style={{ marginTop: 24 }}>
<Typography.Text style={{ fontSize: 13, color: '#1a1a2e', fontWeight: 600, display: 'block', marginBottom: 12 }}>选择视频参数</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>时长</Typography.Text>
<Typography.Text strong style={{ fontSize: 13, color: '#6366f1' }}>{videoDuration}s</Typography.Text>
</div>
<PortalDropdown label="比例" value={videoAspectRatio}
options={engineOptions.ratios}
expanded={expandedEngine === 'ratio'}
onToggle={() => setExpandedEngine(expandedEngine === 'ratio' ? null : 'ratio')}
onSelect={(v) => setVideoAspectRatio(v as AspectRatio)}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown label="分辨率" value={videoResolution}
options={engineOptions.resolutions}
expanded={expandedEngine === 'resolution'}
onToggle={() => setExpandedEngine(expandedEngine === 'resolution' ? null : 'resolution')}
onSelect={(v) => setVideoResolution(v as Resolution)}
onClose={() => setExpandedEngine(null)}
/>
</div>
</div>
)} */}
{/* Image params selection
{mediaType === 'image' && currentRecord && (
<div style={{ marginTop: 24, padding: 16, borderRadius: 12, background: '#f8f9fc' }}>
<div style={{ display: 'flex', justifyContent: 'space-around' }}>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>分辨率</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imageSize || '-'}</Typography.Text>
</div>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>画面比例</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imageProportion || '-'}</Typography.Text>
</div>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>画面尺寸</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imagePx || '-'}</Typography.Text>
</div>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>消耗积分</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>-</Typography.Text>
</div>
</div>
</div>
)} */}
{/* Video credits + generate button */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 20,
padding: "14px 18px",
borderRadius: 12,
background: "#f8f9fc",
border: "1px solid #f0f0f5",
}}
>
{/* Video params selection */}
{mediaType !== "image" && (
<div
style={{
marginTop: 24,
borderRadius: 12,
background: "#f8f9fc",
width: "25%",
}}
>
<Typography.Text
style={{
fontSize: 13,
color: "#1a1a2e",
fontWeight: 600,
display: "block",
marginBottom: 12,
}}
>
选择视频参数
</Typography.Text>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
background: "#f8f9fc",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text style={{ fontSize: 12, color: "#94a3b8" }}>
时长
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 13, color: "#6366f1" }}
>
{videoDuration}s
</Typography.Text>
</div>
<PortalDropdown
label="比例"
value={videoAspectRatio}
options={engineOptions.ratios}
expanded={expandedEngine === "ratio"}
onToggle={() =>
setExpandedEngine(
expandedEngine === "ratio" ? null : "ratio",
)
}
onSelect={(v) => setVideoAspectRatio(v as AspectRatio)}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown
label="分辨率"
value={videoResolution}
options={engineOptions.resolutions}
expanded={expandedEngine === "resolution"}
onToggle={() =>
setExpandedEngine(
expandedEngine === "resolution" ? null : "resolution",
)
}
onSelect={(v) => setVideoResolution(v as Resolution)}
onClose={() => setExpandedEngine(null)}
/>
</div>
</div>
)}
{/* Image params selection */}
{mediaType == "image" && currentRecord && (
<div
style={{
marginTop: 24,
borderRadius: 12,
background: "#f8f9fc",
width: "25%",
}}
>
<div
style={{ display: "flex", justifyContent: "space-around" }}
>
<div style={{ textAlign: "center" }}>
<Typography.Text
style={{
fontSize: 11,
color: "#94a3b8",
display: "block",
marginBottom: 4,
}}
>
分辨率
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 14, color: "#1a1a2e" }}
>
{currentRecord.imageSize || "-"}
</Typography.Text>
</div>
<div style={{ textAlign: "center" }}>
<Typography.Text
style={{
fontSize: 11,
color: "#94a3b8",
display: "block",
marginBottom: 4,
}}
>
画面比例
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 14, color: "#1a1a2e" }}
>
{currentRecord.imageProportion || "-"}
</Typography.Text>
</div>
<div style={{ textAlign: "center" }}>
<Typography.Text
style={{
fontSize: 11,
color: "#94a3b8",
display: "block",
marginBottom: 4,
}}
>
画面尺寸
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 14, color: "#1a1a2e" }}
>
{currentRecord.imagePx || "-"}
</Typography.Text>
</div>
{/* <div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>消耗积分</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>-</Typography.Text>
</div> */}
</div>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div>
<Typography.Text
style={{ fontSize: 11, color: "#94a3b8", display: "block" }}
>
文字积分
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 14, color: "#6366f1" }}
>
{lastTextCredits}
</Typography.Text>
</div>
<Typography.Text style={{ color: "#cbd5e1", fontSize: 18 }}>
+
</Typography.Text>
<div>
<Typography.Text
style={{ fontSize: 11, color: "#94a3b8", display: "block" }}
>
预估{mediaType === "image" ? "图片" : "视频"}积分
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 14, color: "#10b981" }}
>
{mediaType === "image" && currentRecord.imageSize
? getImageCreditsFromCimage(currentRecord.imageSize) || estimatedVideoCredits
: calcVideoCredits(videoDuration, videoResolution)}
</Typography.Text>
</div>
<Typography.Text style={{ color: "#cbd5e1", fontSize: 18 }}>
=
</Typography.Text>
<div>
<Typography.Text
style={{ fontSize: 11, color: "#94a3b8", display: "block" }}
>
总计
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 16, color: "#1a1a2e" }}
>
{(lastTextCredits + (mediaType === "image" ? getImageCreditsFromCimage(currentRecord.imageSize) : calcVideoCredits(videoDuration, videoResolution))).toFixed(2)}
</Typography.Text>
</div>
</div>
<div style={{ display: "flex", gap: 12 }}>
<Button
size="large"
onClick={() => {
setShowOptimized(false);
setCurrentRecord(null);
setEditedPrompt("");
setPromptText("");
setReferences([]);
setSelectedOptions({});
setExpandedGroup(null);
setExpandedEngine(null);
form.resetFields();
}}
style={{
borderRadius: 12,
minWidth: 120,
height: 44,
fontWeight: 500,
}}
>
配置新{mediaType === "image" ? "图片" : "视频"}
</Button>
<Tooltip title={!canAffordVideo ? "积分不足" : ""}>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => handleGenerate(currentRecord.id)}
loading={recordStates[currentRecord.id] === "generating"}
disabled={
!canAffordVideo ||
recordStates[currentRecord.id] === "generating" ||
recordStates[currentRecord.id] === "done"
}
style={{
borderRadius: 12,
minWidth: 180,
height: 44,
fontWeight: 600,
background:
recordStates[currentRecord.id] === "done"
? "#10b981"
: canAffordVideo
? "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)"
: "#d1d5db",
border: "none",
boxShadow: canAffordVideo
? "0 8px 24px rgba(99,102,241,0.3)"
: "none",
}}
>
{recordStates[currentRecord.id] === "done"
? "生成完成"
: recordStates[currentRecord.id] === "generating"
? "生成中..."
: `生成${mediaType === "image" ? "图片" : "视频"} (${ (mediaType === "image" ? getImageCreditsFromCimage(currentRecord.imageSize) : calcVideoCredits(videoDuration, videoResolution))}积分)`}
</Button>
</Tooltip>
</div>
</div>
{/* Reference thumbnails */}
{currentRecord.references && currentRecord.references.length > 0 && (
<div style={{ marginTop: 16 }}>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 8,
}}
>
参考内容
</Typography.Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{currentRecord.references.map((ref, i) => (
<div
key={i}
style={{
width: 64,
height: 64,
borderRadius: 10,
overflow: "hidden",
border: "1px solid #e2e8f0",
position: "relative",
cursor: "pointer",
}}
onClick={() => {
if (ref.type === "video") {
setPreviewVideoUrl(
`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`,
);
}
}}
>
{ref.type === "image" ? (
<Image
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
preview
/>
) : (
<VideoThumb
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
width={64}
height={64}
/>
)}
<Tag
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
margin: 0,
fontSize: 9,
textAlign: "center",
borderRadius: "0 0 8px 8px",
background: "rgba(0,0,0,0.5)",
border: "none",
color: "#fff",
padding: "0 2px",
}}
>
{ref.name}
</Tag>
</div>
))}
</div>
</div>
)}
</Card>
)}
{/* ========== History records ========== */}
{projectRecords.length > 0 && (
<div style={{ marginTop: 24 }}>
<Typography.Text
strong
style={{ fontSize: 15, display: "block", marginBottom: 12 }}
>
本项目生成记录
</Typography.Text>
<div
style={{ display: "flex", flexDirection: "column", gap: 8, background: "#fff", borderRadius: 12, padding: 8, border: "1px solid #f0f0f5" }}
className="stagger-children"
>
{projectRecords.map((record, i) => {
const status = getRecordStatus(record);
const prompt =
editablePrompts[record.id] ??
(record.optimizedPrompt || record.originalPrompt);
const isExpanded = expandedHistoryId === record.id;
const type: any = record.genType || "image";
// console.log(record.genType);
return (
<div
key={record.id}
className="animate-slideInCard"
style={{ animationDelay: `${i * 0.05}s` }}
>
<div
className="record-item"
onClick={() =>
setExpandedHistoryId(isExpanded ? null : record.id)
}
style={{
padding: "12px 16px",
borderRadius: isExpanded ? "8px 8px 0 0" : 8,
background: "#fafafa",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{/* <div style={{ width: 28, height: 28, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, }}>
{type === 'image' ? <span style={{ color: '#d97706', fontSize: 12 }} >图片</span> : <span style={{ color: '#2563eb', fontSize: 12 }} >视频</span>}
</div> */}
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
background: type === "image" ? "#fef3c7" : "#dbeafe",
}}
>
{type === "image" ? (
<PictureOutlined
style={{ color: "#d97706", fontSize: 12 }}
/>
) : (
<VideoCameraOutlined
style={{ color: "#2563eb", fontSize: 12 }}
/>
)}
</div>
{isExpanded ? (
<CaretDownOutlined
style={{
color: "#6366f1",
fontSize: 11,
flexShrink: 0,
}}
/>
) : (
<CaretRightOutlined
style={{
color: "#cbd5e1",
fontSize: 11,
flexShrink: 0,
}}
/>
)}
<Tag
color={statusConfig[status]?.color}
icon={statusConfig[status]?.icon}
style={{ margin: 0 }}
>
{statusConfig[status]?.text}
</Tag>
<Typography.Text
style={{
flex: 1,
fontSize: 13,
color: "#475569",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{prompt}
</Typography.Text>
{status === "prompt_optimized" && (
<Tag
color="processing"
style={{
padding: "3px 10px",
fontSize: 12,
margin: 0,
flexShrink: 0,
}}
>
待配置
</Tag>
)}
{type === "image" ? (
<span
className="mobile-meta"
translate="no"
style={{
fontSize: 12,
color: "#94a3b8",
flexShrink: 0,
}}
>
{record.duration ? `${record.imageSize}` : "-"} ·{" "}
{record.imageProportion || "-"} ·{" "}
{record.imagePx || "-"} ·{" "}
<span className="date-display" translate="no">
{formatDate(record.createdAt)}
</span>
</span>
) : (
<span
className="mobile-meta"
translate="no"
style={{
fontSize: 12,
color: "#94a3b8",
flexShrink: 0,
}}
>
{record.duration ? `${record.duration}秒` : "-"} ·{" "}
{record.aspectRatio || "-"} · {record.resolution || "-"}{" "}
·{" "}
<span className="date-display" translate="no">
{formatDate(record.createdAt)}
</span>
</span>
)}
<div
className="record-actions"
onClick={(e) => e.stopPropagation()}
>
{/* <Space>
{status === "failed" && (
<Button
type="primary"
danger
size="small"
icon={<PlayCircleOutlined />}
loading={generating[record.id]}
onClick={() => handleRetryGeneration(record.id)}
style={{ borderRadius: 8 }}
>
重试
</Button>
)}
</Space> */}
</div>
</div>
{isExpanded && (
<div
style={{
background: "#fff",
borderRadius: "0 0 8px 8px",
padding: "18px 20px",
animation: "fadeInUp 0.25s ease both",
}}
>
<div
className="gen-detail-row"
style={{ display: "flex", gap: 20, flexWrap: "wrap" }}
>
<div style={{ flex: "1 1 50%", minWidth: 260 }}>
{/* Original prompt */}
<div style={{ marginBottom: 14 }}>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 6,
}}
>
原始提示词
</Typography.Text>
<div
style={{
padding: 12,
borderRadius: 10,
background: "#f8f9fc",
border: "1px solid #f0f0f5",
}}
>
<Typography.Text
style={{
fontSize: 13,
color: "#64748b",
lineHeight: 1.7,
}}
>
{record.originalPrompt}
</Typography.Text>
</div>
</div>
{/* Optimized prompt */}
<div style={{ marginBottom: 14 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<Typography.Text
style={{ fontSize: 12, color: "#94a3b8" }}
>
优化后的提示词{" "}
{(status === "prompt_optimized" ||
status === "failed") &&
editingRecordId !== record.id && (
<span style={{ color: "#6366f1" }}>
(可编辑)
</span>
)}
</Typography.Text>
{editingRecordId !== record.id && (
<Space size={4}>
<Tooltip title="复制">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={async () => { const ok = await copyToClipboard(prompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: "#94a3b8" }}
/>
</Tooltip>
{(status === "prompt_optimized" ||
status === "failed") && (
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingRecordId(record.id);
setEditablePrompts((p) => ({
...p,
[record.id]:
p[record.id] ??
record.optimizedPrompt ??
"",
}));
}}
style={{ color: "#6366f1" }}
/>
</Tooltip>
)}
</Space>
)}
</div>
{editingRecordId === record.id ? (
<div>
<Input.TextArea
value={prompt}
onChange={(e) =>
setEditablePrompts((p) => ({
...p,
[record.id]: e.target.value,
}))
}
rows={4}
style={{
borderRadius: 10,
fontSize: 13,
lineHeight: 1.7,
border: "1px solid rgba(99,102,241,0.25)",
background: "rgba(99,102,241,0.02)",
}}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button
size="small"
onClick={() => setEditingRecordId(null)}
>
取消
</Button>
<Button
size="small"
type="primary"
onClick={() => {
setEditablePrompts((p) => ({
...p,
[record.id]: prompt,
}));
setEditingRecordId(null);
message.success("提示词已更新");
}}
style={{
borderRadius: 6,
background:
"linear-gradient(135deg, #6366f1, #8b5cf6)",
border: "none",
}}
>
保存
</Button>
</div>
</div>
) : (
<div
style={{
padding: 12,
borderRadius: 10,
background:
status === "prompt_optimized"
? "rgba(99,102,241,0.02)"
: "#f8f9fc",
border:
status === "prompt_optimized"
? "1px solid rgba(99,102,241,0.1)"
: "1px solid #f0f0f5",
}}
>
<Typography.Text
style={{
fontSize: 13,
color: "#1a1a2e",
lineHeight: 1.7,
}}
>
{prompt}
</Typography.Text>
</div>
)}
</div>
{/* Credits info */}
{type === "image" ? (
<div
style={{
display: "flex",
gap: 14,
padding: "10px 14px",
borderRadius: 10,
background: "#f8f9fc",
}}
>
{[
{
label: "文字积分",
value: `${record.textCreditsCost || 0}`,
},
{
label: "图片积分",
value: `${record.creditsCost || 0}`,
},
{
label: "分辨率",
value: record.imageSize
? `${record.imageSize}`
: "-",
},
{
label: "比例",
value: record.imageProportion || "-",
},
{ label: "尺寸", value: record.imagePx || "-" },
].map((item, j) => (
<div key={j} style={{ flex: 1 }}>
<Typography.Text
style={{
fontSize: 11,
color: "#94a3b8",
display: "block",
}}
>
{item.label}
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 13, color: "#1a1a2e" }}
>
{item.value}
</Typography.Text>
</div>
))}
</div>
) : (
<div
style={{
display: "flex",
gap: 14,
padding: "10px 14px",
borderRadius: 10,
background: "#f8f9fc",
}}
>
{[
{
label: "文字积分",
value: `${record.textCreditsCost || 0}`,
},
{
label: "视频积分",
value: `${record.creditsCost || 0}`,
},
{
label: "时长",
value: record.duration
? `${record.duration}秒`
: "-",
},
{
label: "比例",
value: record.aspectRatio || "-",
},
{
label: "分辨率",
value: record.resolution || "-",
},
].map((item, j) => (
<div key={j} style={{ flex: 1 }}>
<Typography.Text
style={{
fontSize: 11,
color: "#94a3b8",
display: "block",
}}
>
{item.label}
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 13, color: "#1a1a2e" }}
>
{item.value}
</Typography.Text>
</div>
))}
</div>
)}
{/* Error message for failed records */}
{/* {status === 'failed' && record.errorMessage && (
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>
<CloseCircleOutlined style={{ marginRight: 6 }} />
失败原因:{record.errorMessage}
</Typography.Text>
</div>
)} */}
{/* Reference materials */}
{((record.references &&
record.references.length > 0) ||
status === "failed") && (
<div style={{ marginTop: 14 }}>
<Typography.Text
style={{
fontSize: 12,
color: "#94a3b8",
display: "block",
marginBottom: 8,
}}
>
参考素材{" "}
{status === "failed" && (
<span style={{ color: "#6366f1" }}>
(可删除/添加后重试)
</span>
)}
</Typography.Text>
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
}}
>
{record.references?.map(
(ref: any, ri: number) => (
<div
key={ri}
style={{
width: 64,
height: 64,
borderRadius: 10,
overflow: "visible",
border: "1px solid #e2e8f0",
position: "relative",
cursor: "pointer",
}}
onClick={() => {
if (ref.type === "video") {
setPreviewVideoUrl(
`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`,
);
}
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: 10,
overflow: "hidden",
}}
>
{ref.type === "image" ? (
<Image
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
preview
/>
) : (
<VideoThumb
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`}
width={64}
height={64}
/>
)}
</div>
<Tag
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
margin: 0,
fontSize: 9,
textAlign: "center",
borderRadius: "0 0 8px 8px",
background: "rgba(0,0,0,0.5)",
border: "none",
color: "#fff",
padding: "0 2px",
}}
>
{ref.name}
</Tag>
{status === "failed" && (
<div
className="ref-delete"
onClick={(e) => {
e.stopPropagation();
deleteUpload(ref.url).catch(
() => { },
);
// Remove from record references via store update
const newRefs =
record.references!.filter(
(_: any, j: number) => j !== ri,
);
useAppStore
.getState()
.updateRecordReferences(
record.id,
newRefs,
);
}}
style={{
position: "absolute",
top: -4,
right: -4,
width: 16,
height: 16,
borderRadius: "50%",
background: "rgba(239,68,68,0.8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 10,
opacity: 0,
transition: "opacity 0.15s",
}}
>
<CloseCircleOutlined
style={{
color: "#fff",
fontSize: 10,
}}
/>
</div>
)}
</div>
),
)}
{status === "failed" && (
<Upload
accept="image/*,video/*"
showUploadList={false}
multiple
beforeUpload={(file) => {
const isImage =
file.type.startsWith("image/");
const isVideo =
file.type.startsWith("video/");
if (!isImage && !isVideo) {
message.error("仅支持图片或视频文件");
return false;
}
if (isImage) {
uploadImage(file)
.then((res) => {
const newRef = {
url: res.url,
type: "image" as const,
name: file.name || "图片",
};
const curRefs =
record.references || [];
useAppStore
.getState()
.updateRecordReferences(record.id, [
...curRefs,
newRef,
]);
message.success("上传成功");
})
.catch(() => message.error("上传失败"));
} else {
new Promise<number>((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve(video.duration || 0);
video.remove();
};
video.onerror = () => {
resolve(0);
video.remove();
};
video.src = URL.createObjectURL(file);
}).then((fileDuration) => {
uploadVideo(file, fileDuration)
.then((res) => {
const newRef = {
url: res.url,
type: "video" as const,
name: file.name || "视频",
duration: fileDuration || undefined,
};
const curRefs =
record.references || [];
useAppStore
.getState()
.updateRecordReferences(record.id, [
...curRefs,
newRef,
]);
message.success("上传成功");
})
.catch(() => message.error("上传失败"));
});
}
return false;
}}
>
<Tooltip title="添加参考素材">
<div
style={{
width: 64,
height: 64,
borderRadius: 10,
border: "1.5px dashed #d9d9d9",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor =
"#6366f1";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor =
"#d9d9d9";
}}
>
<PlusOutlined
style={{
fontSize: 18,
color: "#94a3b8",
}}
/>
</div>
</Tooltip>
</Upload>
)}
</div>
</div>
)}
</div>
{/* Right: video or status */}
<div
style={{
flex: "1 1 40%",
minWidth: 220,
position: "relative",
}}
>
{/* 将参数选择和视频生成按钮放到视频视频框 */}
{status === "prompt_optimized" && (
<div
style={{
marginTop: 16,
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "transparent",
}}
>
{type === "video" && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
marginBottom: 12,
}}
>
<div
translate="no"
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
background: "#f8f9fc",
border: "1px solid #e2e8f0",
marginLeft: 12,
}}
>
<Typography.Text
style={{ fontSize: 12, color: "#94a3b8" }}
>
时长
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 13, color: "#6366f1" }}
>
{record.duration}
</Typography.Text>
</div>
<PortalDropdown
label="比例"
value={
historyParams[record.id]?.aspectRatio ||
"选择比例"
}
options={engineOptions.ratios}
expanded={
expandedEngine === `ratio-${record.id}`
}
onToggle={() =>
setExpandedEngine(
expandedEngine === `ratio-${record.id}`
? null
: `ratio-${record.id}`,
)
}
onSelect={(v) =>
setHistoryParams((prev) => ({
...prev,
[record.id]: {
...prev[record.id],
aspectRatio: v as AspectRatio,
resolution:
prev[record.id]?.resolution || "",
},
}))
}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown
label="分辨率"
value={
historyParams[record.id]?.resolution ||
"选择分辨率"
}
options={engineOptions.resolutions}
expanded={
expandedEngine === `res-${record.id}`
}
onToggle={() =>
setExpandedEngine(
expandedEngine === `res-${record.id}`
? null
: `res-${record.id}`,
)
}
onSelect={(v) =>
setHistoryParams((prev) => ({
...prev,
[record.id]: {
...prev[record.id],
aspectRatio:
prev[record.id]?.aspectRatio || "",
resolution: v as Resolution,
},
}))
}
onClose={() => setExpandedEngine(null)}
/>
</div>
)}
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
block
loading={generating[record.id]}
disabled={
type === "video" &&
(!historyParams[record.id]?.aspectRatio ||
!historyParams[record.id]?.resolution)
}
onClick={async () => {
const params = historyParams[record.id];
// 视频类型需要检查比例和分辨率
if (type === "video") {
if (
!params?.aspectRatio ||
!params?.resolution
) {
message.error("请选择比例和分辨率");
return;
}
if (
userCredits <
calcVideoCredits(
record.duration || 5,
params.resolution,
)
) {
message.error("积分不足,请先充值");
return;
}
}
setGenerating((p) => ({
...p,
[record.id]: true,
}));
setRecordStates((p) => ({
...p,
[record.id]: "generating",
}));
const mediaText =
type === "video" ? "视频" : "图片";
message.loading({
content: `「${projectName}」正在生成${mediaText}...`,
duration: 0,
key: record.id,
});
try {
await generateVideo(record.id, {
aspectRatio: params?.aspectRatio,
resolution: params?.resolution,
});
startPolling(record.id);
message.success({
content: `「${projectName}${mediaText}已提交,正在生成中...`,
key: record.id,
duration: 3,
});
} catch (error: any) {
setRecordStates((p) => ({
...p,
[record.id]: "failed",
}));
const errorMsg = error?.response?.data?.message || error?.message || `${mediaText}生成失败`;
message.error({
content: `「${projectName}${errorMsg}`,
key: record.id,
duration: 3,
});
} finally {
setGenerating((p) => ({
...p,
[record.id]: false,
}));
}
}}
style={{
borderRadius: 12,
fontWeight: 600,
color: "#fff",
height: 48,
background:
"linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
border: "none",
position: "absolute",
bottom: 18,
left: 0,
}}
>
生成{type === "video" ? "视频" : "图片"}
{type === "video" ? (
<span
style={{
color: "#d2d2d2",
fontSize: "14px",
marginLeft: "6px",
}}
>
(请选择上方的比例分辨率)
</span>
) : (
""
)}{" "}
{/* {type === "video" &&
historyParams[record.id]?.resolution
? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)`
: ""} */}
{type === "video" &&
historyParams[record.id]?.resolution
? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)`
: ""}
{type === "image" && record.imageSize
? `(${getImageCredits(record.imageSize)}积分)`
: type === "image"
? `(积分)`
: ""}
</Button>
</div>
)}
{status === "failed" && (
<div
style={{
position: "absolute",
bottom: 18,
right: 20,
}}
>
{/* <Button
type="primary"
danger
size="large"
icon={<PlayCircleOutlined />}
block
loading={generating[record.id]}
onClick={() => handleRetryGeneration(record.id)}
style={{
borderRadius: 12,
fontWeight: 600,
height: 48,
}}
>
重新生成{type === "video" ? "视频" : "图片"}
</Button> */}
</div>
)}
{status === "generating" ? (
<div
style={{
height: "100%",
minHeight: 180,
borderRadius: 12,
background:
"linear-gradient(135deg, rgba(99,102,241,0.04), rgba(139,92,246,0.04))",
border: "1px dashed rgba(99,102,241,0.2)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
}}
>
<LoadingOutlined
style={{ color: "#6366f1", fontSize: 28 }}
spin
/>
<Typography.Text
style={{ color: "#94a3b8", fontSize: 13 }}
>
{type === "video" ? "视频" : "图片"}生成中...
</Typography.Text>
</div>
) : (status === "completed" && record.videoUrl) ||
record.imageUrl ? (
<div
style={{
borderRadius: 12,
overflow: "hidden",
border: "1px solid #f0f0f5",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: 10,
right: 10,
zIndex: 10,
}}
>
<Tooltip
title={`下载${type === "video" ? "视频" : "图片"}`}
>
<Button
size="small"
icon={<DownloadOutlined />}
onClick={() => {
const a = document.createElement("a");
a.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${type === "video" ? record.videoUrl : record.imageUrl}&download=1`;
a.download = `${record.projectName}${type === "video" ? ".mp4" : ".png"}`;
a.click();
}}
style={{
background: "rgba(0,0,0,0.5)",
border: "none",
color: "#fff",
backdropFilter: "blur(4px)",
borderRadius: 8,
}}
>
下载
</Button>
</Tooltip>
</div>
<div
style={{ minHeight: 180, overflow: "hidden" }}
>
{type === "video" ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${record.videoUrl}`}
controls
preload="metadata"
style={{
width: "100%",
height: "400px",
borderRadius: 0,
}}
/>
) : (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${record.imageUrl}`}
alt={record.projectName}
style={{
width: "100%",
maxHeight: "400px",
objectFit: "contain",
borderRadius: 0,
}}
/>
)}
</div>
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#fafbff",
}}
>
<Space>
{type === "video" ? (
<VideoCameraOutlined
style={{ color: "#6366f1", fontSize: 13 }}
/>
) : (
<PictureOutlined
style={{ color: "#6366f1", fontSize: 13 }}
/>
)}
<Typography.Text
style={{ fontSize: 12, color: "#475569" }}
>
{record.projectName}
</Typography.Text>
</Space>
{record.generatedAt && (
<span
className="date-display"
translate="no"
style={{ fontSize: 11, color: "#94a3b8" }}
>
{formatDate(record.generatedAt)}
</span>
)}
</div>
</div>
) : status === "failed" ? (
<div
style={{
height: "100%",
minHeight: 180,
borderRadius: 12,
background: "rgba(239,68,68,0.03)",
border: "1px dashed rgba(239,68,68,0.2)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: 16,
}}
>
<CloseCircleOutlined
style={{ color: "#ef4444", fontSize: 28 }}
/>
<Typography.Text
style={{
color: "#ef4444",
fontSize: 14,
fontWeight: 600,
}}
>
{type === "video" ? "视频" : "图片"}生成失败
</Typography.Text>
{record.errorMessage && (
<Typography.Text
style={{
color: "#ef4444",
fontSize: 12,
textAlign: "center",
lineHeight: 1.6,
}}
>
{record.errorMessage}
</Typography.Text>
)}
</div>
) : (
<div
style={{
height: "100%",
minHeight: 180,
borderRadius: 12,
background: "#f8f9fc",
border: "1px dashed #e2e8f0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
}}
>
<ClockCircleOutlined
style={{ color: "#94a3b8", fontSize: 28 }}
/>
<Typography.Text
style={{ color: "#94a3b8", fontSize: 13 }}
>
{type === "image"
? "待生成图片"
: status === "prompt_optimized"
? "待配置视频参数"
: "待生成视频"}
</Typography.Text>
</div>
)}
</div>
{/* Generate buttons for history records */}
{/* {status === 'prompt_optimized' && (
<div style={{ marginTop: 16, width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<div translate="no" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>时长</Typography.Text>
<Typography.Text strong style={{ fontSize: 13, color: '#6366f1' }}>{record.duration}秒</Typography.Text>
</div>
<PortalDropdown label="比例" value={historyParams[record.id]?.aspectRatio || '选择比例'}
options={engineOptions.ratios}
expanded={expandedEngine === `ratio-${record.id}`}
onToggle={() => setExpandedEngine(expandedEngine === `ratio-${record.id}` ? null : `ratio-${record.id}`)}
onSelect={(v) => setHistoryParams(prev => ({ ...prev, [record.id]: { ...prev[record.id], aspectRatio: v as AspectRatio, resolution: prev[record.id]?.resolution || '' } }))}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown label="分辨率" value={historyParams[record.id]?.resolution || '选择分辨率'}
options={engineOptions.resolutions}
expanded={expandedEngine === `res-${record.id}`}
onToggle={() => setExpandedEngine(expandedEngine === `res-${record.id}` ? null : `res-${record.id}`)}
onSelect={(v) => setHistoryParams(prev => ({ ...prev, [record.id]: { ...prev[record.id], aspectRatio: prev[record.id]?.aspectRatio || '', resolution: v as Resolution } }))}
onClose={() => setExpandedEngine(null)}
/>
</div>
<Button type="primary" size="large" icon={<RocketOutlined />} block
loading={generating[record.id]}
disabled={!historyParams[record.id]?.aspectRatio || !historyParams[record.id]?.resolution}
onClick={async () => {
const params = historyParams[record.id];
if (!params?.aspectRatio || !params?.resolution) { message.error('请选择比例和分辨率'); return; }
if (userCredits < calcVideoCredits(record.duration || 5, params.resolution)) { message.error('积分不足,请先充值'); return; }
setGenerating((p) => ({ ...p, [record.id]: true }));
setRecordStates((p) => ({ ...p, [record.id]: 'generating' }));
message.loading({ content: `「${projectName}」正在生成视频...`, duration: 0, key: record.id });
try {
await generateVideo(record.id, { aspectRatio: params.aspectRatio, resolution: params.resolution });
setRecordStates((p) => ({ ...p, [record.id]: 'done' }));
message.success({ content: `「${projectName}」视频生成成功!`, key: record.id, duration: 3 });
} catch {
setRecordStates((p) => ({ ...p, [record.id]: 'failed' }));
message.error({ content: `「${projectName}」视频生成失败`, key: record.id, duration: 3 });
} finally {
setGenerating((p) => ({ ...p, [record.id]: false }));
}
}}
style={{ borderRadius: 12, fontWeight: 600, height: 48, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}>
生成视频 {historyParams[record.id]?.resolution ? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)` : ''}
</Button>
</div>
)}
{status === 'failed' && (
<div style={{ marginTop: 16 }}>
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />} block
loading={generating[record.id]}
onClick={() => handleRetryGeneration(record.id)}
style={{ borderRadius: 12, fontWeight: 600, height: 48 }}>重新生成视频</Button>
</div>
)} */}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Video preview modal */}
<Modal
open={!!previewVideoUrl}
onCancel={() => setPreviewVideoUrl(null)}
footer={null}
width={480}
centered
destroyOnHidden
closable={false}
title={null}
styles={{
body: { padding: 0, background: "#000", position: "relative" },
}}
>
{previewVideoUrl && (
<>
<video
src={previewVideoUrl}
controls
autoPlay
style={{ width: "100%", display: "block", maxHeight: "70vh" }}
/>
<div
onClick={() => setPreviewVideoUrl(null)}
style={{
position: "absolute",
top: 8,
right: 8,
width: 28,
height: 28,
borderRadius: "50%",
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 10,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.85)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.6)";
}}
>
<XOutlined style={{ color: "#fff", fontSize: 16 }} />
</div>
</>
)}
</Modal>
</div>
);
};
export default GeneratePage;