Files
video-gen/video-gen-app/src/pages/GeneratePage.tsx
T
2026-05-26 13:47:24 +08:00

4257 lines
170 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 } from "react";
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,
} 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,
} from "../api";
import { formatDate } from "../utils/formatDate";
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 { 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 [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>("image");
// 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>([]);
// 点击空白处关闭图片设置浮层
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 resolution = selectedResolution === '4k' ? 4096 : 2048;
// const ratioParts = ratio.split(':');
// if (ratio === 'auto') {
// // 智能模式保持当前尺寸
// return;
// }
// const w = parseInt(ratioParts[0]);
// const h = parseInt(ratioParts[1]);
// if (w >= h) {
// // 横向或正方形
// setWidth(resolution);
// setHeight(Math.round(resolution * h / w));
// } else {
// // 纵向
// setHeight(resolution);
// setWidth(Math.round(resolution * w / h));
// }
};
const calculateSizeFromRatiotwo = (ratio: string) => {
setFBlindex(parseInt(ratio));
};
// 图片比例选项
const [ratioOptions, setRatioOptions] = useState([]);
// 图片分辨率选项
const [resolutionOptions, setResolutionOptions] = useState([]);
const [widthandheight, setWidthandHeight] = useState([]);
const [blindex, setBlindex] = useState<any>(0);
const [fblindex, setFBlindex] = useState<any>(0);
// 监听 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) => {
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
let supportedResolutions = [];
let twokwidth = [];
let fourkwidth = [];
for (let key in supportedSizes["2K"]) {
supportedResolutions.push(key);
twokwidth.push(supportedSizes["2K"][key]);
}
supportedResolutions.forEach((res, index) => {
setRatioOptions((prev) => [
...prev,
{ value: res, label: String(index) },
]);
});
for (let key in supportedSizes["4K"]) {
fourkwidth.push(supportedSizes["4K"][key]);
}
const newWidthandHeight = [twokwidth, fourkwidth];
setWidthandHeight(newWidthandHeight);
let supportedRatios = [];
for (let key in supportedSizes) {
supportedRatios.push(key);
}
supportedRatios.forEach((ratio, index) => {
setResolutionOptions((prev) => [
...prev,
{ value: ratio, label: String(index) },
]);
});
})
.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);
// console.log(fetchRecords(projectId));
}, [projectId]);
// Auto-poll: if any records are still generating/optimizing after page load, start polling
const autoPollStartedRef = useRef(false);
useEffect(() => {
if (autoPollStartedRef.current || records.length === 0) return;
autoPollStartedRef.current = true;
console.log('aaaa',records);
records.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);
}
});
}, [records, projectId]);
// Poll for optimizing records until they trans ition
const startOptimizingPoll = (recordId: string) => {
if (pollTimers.current[recordId]) return;
pollTimers.current[recordId] = setInterval(async () => {
await fetchRecords(projectId);
const latest = useAppStore
.getState()
.records.find((r) => r.id === recordId);
if (!latest || latest.status !== "optimizing") {
clearInterval(pollTimers.current[recordId]);
delete pollTimers.current[recordId];
}
}, 3000);
};
// 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 || records.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 = records.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");
}
}, [records, projectId]);
const project = projects.find((p) => p.id === projectId);
const projectRecords = records.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;
};
// Video credits estimate for step 2
const estimatedVideoCredits = 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) {
console.error("优化提示词失败:", error);
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 渲染周期影响
*/
const pollTimers = useRef<Record<string, ReturnType<typeof setInterval>>>({});
/**
* 启动任务状态轮询
*
* @param recordId - 任务ID,用于标识唯一的轮询任务
*
* 轮询流程:
* 1. 检查是否已存在相同任务的轮询(防止重复启动)
* 2. 创建定时任务,每5秒执行一次
* 3. 每次轮询:
* - 拉取最新任务列表
* - 查询当前任务的最新状态
* - 判断是否需要终止轮询
* 4. 终止条件:任务状态变为非 generating
* - completed: 任务完成,更新状态并显示成功提示
* - failed: 任务失败,更新状态并显示错误提示
* - 其他: 停止轮询(如已删除、已取消等)
*/
const startPolling = (recordId: string) => {
// 防止重复启动:如果该任务已在轮询中,则直接返回
if (pollTimers.current[recordId]) return;
/**
* 创建定时轮询任务
* 轮询间隔:5000ms5秒)
*
* 使用 async 函数的原因:
* - fetchRecords 是异步操作
* - 需要等待数据更新后再进行状态判断
*/
pollTimers.current[recordId] = setInterval(async () => {
try {
// 拉取最新的任务列表(从后端获取)
await fetchRecords(projectId);
// 从全局状态中查找当前任务的最新状态
const latest = useAppStore
.getState()
.records.find((r) => r.id === recordId);
/**
* 轮询终止条件判断:
* 1. 任务不存在(已被删除)
* 2. 任务状态不再是 generating
*/
if (!latest || latest.status !== "generating") {
// 清除定时器
clearInterval(pollTimers.current[recordId]);
// 从管理对象中移除,释放引用
delete pollTimers.current[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,
});
}
// 其他状态(如 cancelled、archived 等):静默停止轮询
}
} catch (error) {
// 轮询过程中发生错误
console.error(`Polling error for record ${recordId}:`, error);
// 可选:可以在这里添加错误重试逻辑或通知用户
}
}, 5000); // 轮询间隔:5秒
};
/**
* 组件卸载时清理所有轮询定时器
*
* 使用空依赖数组 [] 的原因:
* - 只在组件挂载时执行一次
* - 返回的清理函数在组件卸载时执行
*
* 清理逻辑:
* 1. 获取所有定时器ID
* 2. 逐个清除定时器
* 3. pollTimers.current 会被自动垃圾回收
*/
useEffect(() => {
return () => {
// 遍历所有定时器并清除
Object.values(pollTimers.current).forEach(clearInterval);
};
}, []);
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 {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
message.error({
content: `「${projectName}」提交失败`,
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" }));
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 {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
message.error({
content: `「${projectName}」提交失败`,
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>
{/* Header */}
<div
className="gen-header animate-fadeInUp"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
padding: "20px 28px",
borderRadius: 16,
background: "linear-gradient(135deg, #1e1b4b 0%, #312e81 100%)",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
right: -30,
bottom: -30,
width: 160,
height: 160,
borderRadius: "50%",
background: "rgba(255,255,255,0.05)",
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
position: "relative",
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/projects")}
style={{
background: "rgba(255,255,255,0.1)",
border: "1px solid rgba(255,255,255,0.2)",
color: "#fff",
borderRadius: 10,
}}
/>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Typography.Title
level={4}
style={{ margin: 0, color: "#fff", fontWeight: 700 }}
>
{projectName}
</Typography.Title>
{project && (
<Tag
style={{
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.2)",
color: "rgba(255,255,255,0.8)",
borderRadius: 6,
}}
>
{industryLabels[project.industry] || project.industry}
</Tag>
)}
</div>
<Typography.Text
style={{ color: "rgba(255,255,255,0.5)", fontSize: 13 }}
>
描述视频 AI优化 生成视频
</Typography.Text>
</div>
</div>
<Tag
style={{
background: "rgba(255,255,255,0.1)",
border: "1px solid rgba(255,255,255,0.2)",
color: "#fff",
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="image"
buttonStyle="solid"
onChange={handleMediaTypeChange}
>
{/* 图片选项 */}
<Radio.Button value="image" style={{ zIndex: 0 }}>
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />{" "}
{/* 图片图标 */}
图片
</Radio.Button>
{/* 视频选项 */}
<Radio.Button value="video">
<VideoCameraOutlined
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>
))}
<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;
}
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;
if (isImage && imageCount >= 10) {
message.error("最多上传10张图片");
return false;
}
if (isVideo && videoCount >= 3) {
message.error("最多上传3个视频");
return false;
}
setUploading(true);
const uploadFn = isImage ? uploadImage : uploadVideo;
uploadFn(file)
.then((res) => {
const typeLabel = isImage ? "图片" : "视频";
const typeCount = isImage
? imageCount + 1
: videoCount + 1;
setReferences((prev) => [
...prev,
{
url: res.url,
type: isImage ? "image" : "video",
name: `${typeLabel}${typeCount}`,
},
]);
message.success(`${typeLabel}上传成功`);
})
.catch(() => message.error("上传失败"))
.finally(() => setUploading(false));
return false;
}}
>
<Tooltip title={`参考内容(${references.length}/10`}>
<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>
</Tooltip>
</Upload>
</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);
}}
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 == "2K" ? "高清 2K" : "超清 4K"}
| {width}×{height}
{/* {selectedResolution === '2k' ? '高清 2K' : '超清 4K'} | {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: item.value === "auto" ? 16 : 20,
height: item.value === "auto" ? 16 : 20,
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 */}
<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>
{/* Prompts comparison */}
<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={() => {
navigator.clipboard.writeText(editedPrompt);
message.success("已复制");
}}
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>
{/* 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))}
</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>
{/* Generation state */}
{recordStates[currentRecord.id] &&
recordStates[currentRecord.id] !== "idle" && (
<div
style={{
marginTop: 18,
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" &&
`${mediaType === "image" ? "图片" : "视频"}生成中,请稍候...`}
{recordStates[currentRecord.id] === "done" &&
`${mediaType === "image" ? "图片" : "视频"}生成成功`}
{recordStates[currentRecord.id] === "failed" &&
`${mediaType === "image" ? "图片" : "视频"}生成失败,请重试`}
</Typography.Text>
</div>
)}
{/* Completed video player */}
{recordStates[currentRecord.id] === "done" &&
currentRecord.videoUrl && (
<div style={{ marginTop: 18 }}>
<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: 400, display: "block" }}
/>
</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 }}
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 ? "12px 12px 0 0" : 12,
background: "#fff",
border: "1px solid #f0f0f5",
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",
border: "1px solid #f0f0f5",
borderTop: "none",
borderRadius: "0 0 12px 12px",
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={() => {
navigator.clipboard.writeText(prompt);
message.success("已复制");
}}
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;
}
const uploadFn = isImage
? uploadImage
: uploadVideo;
uploadFn(file)
.then((res) => {
const newRef = {
url: res.url,
type: isImage
? ("image" as const)
: ("video" as const),
name:
file.name ||
(isImage ? "图片" : "视频"),
};
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,
});
setRecordStates((p) => ({
...p,
[record.id]: "done",
}));
message.success({
content: `「${projectName}${mediaText}生成成功!`,
key: record.id,
duration: 3,
});
} catch {
setRecordStates((p) => ({
...p,
[record.id]: "failed",
}));
message.error({
content: `「${projectName}${mediaText}生成失败`,
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}`;
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
destroyOnClose
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)";
}}
>
<CloseCircleOutlined style={{ color: "#fff", fontSize: 16 }} />
</div>
</>
)}
</Modal>
</div>
);
};
export default GeneratePage;