4257 lines
170 KiB
TypeScript
4257 lines
170 KiB
TypeScript
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→1,video→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;
|
||
|
||
/**
|
||
* 创建定时轮询任务
|
||
* 轮询间隔:5000ms(5秒)
|
||
*
|
||
* 使用 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;
|