video-flow-b/components/ChatInputBox/PcTemplateModal.tsx
2025-09-16 19:38:02 +08:00

725 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect } from "react";
import {
Clapperboard,
Sparkles,
LayoutTemplate,
} from "lucide-react";
import {
Modal,
Tooltip,
Upload,
Image,
} from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import TemplateCard from "./templateCard";
import { useRouter } from "next/navigation";
import { useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import GlobalLoad from "../common/GlobalLoad";
/**
* 防抖函数
* @param {Function} func - 需要防抖的函数
* @param {number} wait - 等待时间(ms)
* @returns {Function} - 防抖后的函数
*/
const debounce = (func: Function, wait: number) => {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
interface PcTemplateModalProps {
isTemplateCreating: boolean;
setIsTemplateCreating: (value: boolean) => void;
isRoleGenerating: { [key: string]: boolean };
setIsRoleGenerating: (value: { [key: string]: boolean } | ((prev: { [key: string]: boolean }) => { [key: string]: boolean })) => void;
isItemGenerating: { [key: string]: boolean };
setIsItemGenerating: (value: { [key: string]: boolean } | ((prev: { [key: string]: boolean }) => { [key: string]: boolean })) => void;
isOpen: boolean;
onClose: () => void;
configOptions: {
mode: "auto" | "manual";
resolution: "720p" | "1080p" | "4k";
language: string;
videoDuration: string;
};
}
/**
* PC端模板故事模式弹窗组件
*/
export const PcTemplateModal = ({
isTemplateCreating,
setIsTemplateCreating,
isRoleGenerating,
setIsRoleGenerating,
isItemGenerating,
setIsItemGenerating,
isOpen,
onClose,
configOptions = {
mode: "auto" as "auto" | "manual",
resolution: "720p" as "720p" | "1080p" | "4k",
language: "english",
videoDuration: "1min",
},
}: PcTemplateModalProps) => {
// 使用 hook 管理状态
const {
templateStoryList,
selectedTemplate,
isLoading,
getTemplateStoryList,
actionStory,
setSelectedTemplate,
AvatarAndAnalyzeFeatures,
updateRoleImage,
updateItemImage,
handleRoleFieldBlur,
handleItemFieldBlur,
clearData,
} = useTemplateStoryServiceHook();
// 使用上传文件hook
const { uploadFile, isUploading } = useUploadFile();
// 本地加载状态,用于 UI 反馈
const [localLoading, setLocalLoading] = useState(0);
// 控制输入框显示状态
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>(
{}
);
const router = useRouter();
// 组件挂载时获取模板列表
useEffect(() => {
if (isOpen) {
getTemplateStoryList();
}
}, [isOpen, getTemplateStoryList]);
// 监听点击外部区域关闭输入框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
// 检查是否点击了输入框相关的元素
if (
!target.closest(".ant-tooltip") &&
!target.closest('[data-alt*="field-ai-button"]')
) {
// 关闭所有打开的输入框
setInputVisible({});
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// 处理模板选择
const handleTemplateSelect = (template: StoryTemplateEntity) => {
setSelectedTemplate(template);
};
// 处理确认操作
const handleConfirm = async () => {
if (!selectedTemplate) return;
if (isTemplateCreating) return;
setIsTemplateCreating(true);
let timer: NodeJS.Timeout | null = null;
try {
// 获取当前用户信息
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
console.error("用户未登录");
return;
}
// 启动进度条动画
timer = setInterval(() => {
setLocalLoading((prev) => {
if (prev >= 95) {
return 95;
}
return prev + 0.1;
});
}, 100);
setLocalLoading(1);
const projectId = await actionStory(
String(User.id),
configOptions.mode,
configOptions.resolution,
configOptions.language
);
if (projectId) {
// 跳转到电影详情页
router.push(`/movies/work-flow?episodeId=${projectId}`);
onClose();
// 重置状态
setSelectedTemplate(null);
}
console.log("Story action created:", projectId);
} catch (error) {
console.error("Failed to create story action:", error);
setIsTemplateCreating(false);
setLocalLoading(0);
// 这里可以添加 toast 提示
// 重置状态
setSelectedTemplate(null);
} finally {
setLocalLoading(0);
if (timer) {
clearInterval(timer);
}
}
};
// 模板列表渲染
const templateListRender = () => {
return (
<div className="w-1/3 p-4 border-r border-white/[0.1] overflow-y-auto">
<div className="space-y-4 overflow-y-auto template-list-scroll">
{templateStoryList.map((template, index) => (
<div
key={template.id}
data-alt={`template-card-${index}`}
className="flex justify-center"
onClick={() => handleTemplateSelect(template)}
>
<TemplateCard
imageUrl={template.image_url[0]}
imageAlt={template.name}
title={template.name}
description={template.generateText}
isSelected={selectedTemplate?.id === template.id}
width={200}
height={280}
/>
</div>
))}
</div>
</div>
);
};
// 故事编辑器渲染
const storyEditorRender = () => {
return selectedTemplate ? (
<div className="relative h-full">
{/* 模板信息头部 - 增加顶部空间 */}
<div className="flex gap-3 py-4 border-b border-white/[0.1] h-[300px]">
{/* 左侧图片 */}
<div className="w-1/4">
<Image
src={selectedTemplate.image_url[0]}
alt={selectedTemplate.name}
className="w-4 h-5 !object-contain transition-all duration-500 group-hover:scale-105 group-hover:rotate-1"
/>
</div>
{/* 右侧信息 - 增加文本渲染空间 */}
<div className="flex-1 flex flex-col">
<h2
data-alt="template-title"
className="text-2xl font-bold text-white mb-2"
>
{selectedTemplate.name}
</h2>
<div
className="flex-1 overflow-y-auto max-h-96 "
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgba(156,163,175,0.2) rgba(0,0,0,0)",
}}
>
<p
data-alt="template-description"
className="text-gray-300 text-sm leading-relaxed"
>
{selectedTemplate.generateText}
</p>
</div>
</div>
</div>
{/* 角色配置区域 */}
{selectedTemplate?.storyRole &&
selectedTemplate.storyRole.length > 0 && (
<div className="pt-2 border-t border-white/10">
<h3
data-alt="roles-section-title"
className="text-lg font-semibold text-white mb-4"
>
Character Configuration
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{selectedTemplate.storyRole.map((role, index) => (
<div
key={index}
data-alt={`role-field-${index}`}
className="flex flex-col items-center space-y-3"
>
{/* 图片容器 */}
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={role.role_description || ""}
onChange={(e) => {
// 更新角色的描述字段
const updatedTemplate = {
...selectedTemplate!,
storyRole: selectedTemplate!.storyRole.map(
(r) =>
r.role_name === role.role_name
? {
...r,
role_description: e.target.value,
}
: r
),
};
setSelectedTemplate(updatedTemplate);
}}
placeholder={role.user_tips}
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{/* AI生成按钮 */}
<ActionButton
isCreating={isRoleGenerating[role.role_name] || false}
handleCreateVideo={async () => {
if (
role.role_description &&
role.role_description.trim()
) {
setIsRoleGenerating(prev => ({...prev, [role.role_name]: true}));
try {
await handleRoleFieldBlur(
role.role_name,
role.role_description.trim()
);
} finally {
setIsRoleGenerating(prev => ({...prev, [role.role_name]: false}));
}
}
setInputVisible((prev) => ({
...prev,
[role.role_name]: false,
}));
}}
icon={<Sparkles className="w-4 h-4" />}
width="w-8"
height="h-8"
disabled={isRoleGenerating[role.role_name] || false}
/>
</div>
</div>
}
placement="top"
classNames={{
root: "max-w-none",
}}
open={inputVisible[role.role_name]}
onOpenChange={(visible) =>
setInputVisible((prev) => ({
...prev,
[role.role_name]: visible,
}))
}
trigger="contextMenu"
styles={{ root: { zIndex: 1000 } }}
>
{/* 图片 */}
<div
data-alt={`role-thumbnail-${index}`}
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
>
<Image
src={role.photo_url || "/assets/empty_video.png"}
alt={role.role_name}
className="w-full h-full object-cover"
preview={{
mask: null,
maskClassName: "hidden",
}}
fallback="/assets/empty_video.png"
/>
</div>
</Tooltip>
{/* 角色名称 - 图片下方 */}
<div className="text-center mt-2">
<span className="text-white text-sm font-medium">
{role.role_name}
</span>
</div>
{/* 按钮组 - 右上角 */}
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* AI生成按钮 */}
<Tooltip title="AI generate image" placement="top">
<button
data-alt={`role-ai-button-${index}`}
onClick={() =>
setInputVisible((prev) => ({
...prev,
[role.role_name]: !prev[role.role_name],
}))
}
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
</Tooltip>
{/* 上传按钮 */}
<Upload
name="roleImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
console.error("只能上传图片文件");
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
console.error("图片大小不能超过5MB");
return false;
}
return true;
}}
customRequest={async ({
file,
onSuccess,
onError,
}) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(
fileObj,
(progress) => {
console.log(`上传进度: ${progress}%`);
}
);
await AvatarAndAnalyzeFeatures(
uploadedUrl,
role.role_name
);
onSuccess?.(uploadedUrl);
} catch (error) {
console.error("角色图片上传失败:", error);
onError?.(error as Error);
}
}}
>
<Tooltip title="upload your image" placement="top">
<button
data-alt={`role-upload-button-${index}`}
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 道具配置区域 */}
{selectedTemplate?.storyItem &&
selectedTemplate.storyItem.length > 0 && (
<div className="pt-2 border-t border-white/10">
<h3
data-alt="items-section-title"
className="text-lg font-semibold text-white mb-4"
>
props Configuration
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{selectedTemplate.storyItem.map((item, index) => (
<div
key={index}
data-alt={`item-field-${index}`}
className="flex flex-col items-center space-y-3"
>
{/* 图片容器 */}
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={item.item_description || ""}
onChange={(e) => {
// 更新道具的描述字段
const updatedTemplate = {
...selectedTemplate!,
storyItem: selectedTemplate!.storyItem.map(
(i) =>
i.item_name === item.item_name
? {
...i,
item_description: e.target.value,
}
: i
),
};
setSelectedTemplate(updatedTemplate);
}}
placeholder="Enter description for AI image generation..."
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{/* AI生成按钮 */}
<ActionButton
isCreating={isItemGenerating[item.item_name] || false}
handleCreateVideo={async () => {
if (
item.item_description &&
item.item_description.trim()
) {
setIsItemGenerating(prev => ({...prev, [item.item_name]: true}));
try {
await handleItemFieldBlur(
item.item_name,
item.item_description.trim()
);
} finally {
setIsItemGenerating(prev => ({...prev, [item.item_name]: false}));
}
}
setInputVisible((prev) => ({
...prev,
[item.item_name]: false,
}));
}}
icon={<Sparkles className="w-4 h-4" />}
width="w-8"
height="h-8"
disabled={isItemGenerating[item.item_name] || false}
/>
</div>
</div>
}
placement="top"
classNames={{
root: "max-w-none",
}}
open={inputVisible[item.item_name]}
onOpenChange={(visible) =>
setInputVisible((prev) => ({
...prev,
[item.item_name]: visible,
}))
}
trigger="contextMenu"
styles={{ root: { zIndex: 1000 } }}
>
{/* 图片 */}
<div
data-alt={`item-thumbnail-${index}`}
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
>
<Image
src={item.photo_url || "/assets/empty_video.png"}
alt={item.item_name}
className="w-full h-full object-cover"
preview={{
mask: null,
maskClassName: "hidden",
}}
fallback="/assets/empty_video.png"
/>
</div>
</Tooltip>
{/* 道具名称 - 图片下方 */}
<div className="text-center mt-2">
<span className="text-white text-sm font-medium">
{item.item_name}
</span>
</div>
{/* 按钮组 - 右上角 */}
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* AI生成按钮 */}
<Tooltip title="AI generate image" placement="top">
<button
data-alt={`item-ai-button-${index}`}
onClick={() =>
setInputVisible((prev) => ({
...prev,
[item.item_name]: !prev[item.item_name],
}))
}
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
</Tooltip>
{/* 上传按钮 */}
<Upload
name="itemImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
console.error("只能上传图片文件");
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
console.error("图片大小不能超过5MB");
return false;
}
return true;
}}
customRequest={async ({
file,
onSuccess,
onError,
}) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(
fileObj,
(progress) => {
console.log(`上传进度: ${progress}%`);
}
);
updateItemImage(item.item_name, uploadedUrl);
onSuccess?.(uploadedUrl);
} catch (error) {
console.error("道具图片上传失败:", error);
onError?.(error as Error);
}
}}
>
<Tooltip title="upload your image" placement="top">
<button
data-alt={`item-upload-button-${index}`}
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
</div>
</div>
))}
</div>
</div>
)}
<div className=" absolute -bottom-8 right-0 w-full flex items-center justify-end gap-2">
{/** 自由输入文字 */}
{(selectedTemplate?.freeInput) && selectedTemplate.freeInput.length > 0 && (
<div className="py-2 flex-1">
<input
type="text"
value={selectedTemplate?.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate?.freeInput[0].user_tips}
className="w-full px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
...selectedTemplate!,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value
})),
}
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
}}
/>
</div>
)}
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-5 h-5" />}
disabled={isTemplateCreating || localLoading > 0}
/>
</div>
</div>
) : (
<div className="flex items-center justify-center h-64">
<div className="text-center text-white/60">
<LayoutTemplate className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p className="text-lg">No templates available</p>
<p className="text-sm">Please try again later</p>
</div>
</div>
);
};
return (
<>
<Modal
open={isOpen}
onCancel={() => {
// 清空所有选中的内容数据
clearData();
onClose();
}}
footer={null}
width="60%"
closable={false}
maskClosable={false}
style={{ maxWidth: "800px", marginTop: "0vh" }}
className="photo-story-modal !pb-0 rounded-lg bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
>
<GlobalLoad show={isLoading} progress={localLoading}>
<div className="rounded-2xl h-[70vh] overflow-y-hidden flex flex-col">
{/* 弹窗头部 */}
<div className="flex items-center justify-between px-4 pb-6 border-b border-white/[0.1]">
<h2 className="text-2xl font-bold text-white">
Template Story Selection
</h2>
<button
onClick={onClose}
className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors"
>
<span className="text-white/70 text-lg leading-none">×</span>
</button>
</div>
<div className="flex gap-4 pb-8 flex-1 overflow-y-hidden">
{templateListRender()}
<div className="flex-1">{storyEditorRender()}</div>
</div>
</div>
</GlobalLoad>
</Modal>
</>
);
};