列表页适配H5

This commit is contained in:
北枳 2025-09-15 11:21:43 +08:00
parent dd0b78ce9d
commit 84caabe69a
11 changed files with 2221 additions and 1479 deletions

View File

@ -492,6 +492,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
} }
} catch (error) { } catch (error) {
console.error("创建电影项目失败:", error); console.error("创建电影项目失败:", error);
throw error;
} }
}, },
[ [

View File

@ -276,6 +276,7 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
return result.project_id; return result.project_id;
} catch (error) { } catch (error) {
console.error("创建电影项目失败:", error); console.error("创建电影项目失败:", error);
throw error;
} finally { } finally {
// 清除 loading 状态 // 清除 loading 状态
setIsLoading(false); setIsLoading(false);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,396 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Drawer, Popconfirm, Tooltip, Upload } from "antd";
import { ImagePlay, Sparkles, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import GlobalLoad from "../common/GlobalLoad";
import { ActionButton } from "../common/ActionButton";
import { HighlightEditor } from "../common/HighlightEditor";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
import { useLoadScriptText } from "@/app/service/domain/service";
type ConfigOptions = {
mode: "auto" | "manual";
resolution: "720p" | "1080p" | "4k";
language: string;
videoDuration: string;
};
/**
* / PcPhotoStoryModal
* -
* -
* -
* -
*
* @param {boolean} isOpen -
* @param {() => void} onClose -
* @param {boolean} isCreating -
* @param {(v: boolean) => void} setIsCreating -
* @param {boolean} isPhotoCreating -
* @param {(v: boolean) => void} setIsPhotoCreating -
* @param {ConfigOptions} configOptions - PC
*/
export const H5PhotoStoryDrawer = ({
isMobile,
isOpen,
onClose,
isCreating,
setIsCreating,
isPhotoCreating,
setIsPhotoCreating,
configOptions = {
mode: "auto",
resolution: "720p",
language: "english",
videoDuration: "1min",
},
}: {
isMobile: boolean;
isOpen: boolean;
onClose: () => void;
isCreating: boolean;
setIsCreating: (value: boolean) => void;
isPhotoCreating: boolean;
setIsPhotoCreating: (value: boolean) => void;
configOptions?: ConfigOptions;
}) => {
const {
activeImageUrl,
storyContent,
potentialGenres,
selectedCategory,
isLoading,
hasAnalyzed,
taskProgress,
updateStoryType,
updateStoryContent,
updateCharacterName,
resetImageStory,
triggerFileSelection,
avatarComputed,
uploadAndAnalyzeImage,
setCharactersAnalysis,
originalUserDescription,
actionMovie,
uploadCharacterAvatarAndAnalyzeFeatures,
} = useImageStoryServiceHook();
const { loadingText } = useLoadScriptText(isLoading);
const [localLoading, setLocalLoading] = useState(0);
const router = useRouter();
const taskProgressRef = useRef(taskProgress);
const [cursorPosition, setCursorPosition] = useState(0);
const handleCursorPositionChange = (position: number) => {
setCursorPosition(position);
};
useEffect(() => {
taskProgressRef.current = taskProgress;
}, [taskProgress]);
const handleImageUpload = async (e: any) => {
const target = e.target as HTMLImageElement;
if (!(target.tagName == "IMG" || e.target.dataset.alt == "image-upload-area")) {
return;
}
e.preventDefault();
e.stopPropagation();
try {
await triggerFileSelection();
} catch (error) {
// 保持静默失败,避免打断用户
}
};
const handleConfirm = async () => {
try {
setIsCreating(true);
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
setIsCreating(false);
return;
}
const episodeResponse = await actionMovie(
String(User.id),
configOptions.mode as "auto" | "manual",
configOptions.resolution as "720p" | "1080p" | "4k",
configOptions.language
);
if (!episodeResponse) return;
const episodeId = episodeResponse.project_id;
router.push(`/movies/work-flow?episodeId=${episodeId}`);
onClose();
} catch (error) {
setIsCreating(false);
}
};
const handleAnalyzeImage = async () => {
if (isPhotoCreating || isLoading) return;
setIsPhotoCreating(true);
let timeout = 100;
let timer: NodeJS.Timeout;
timer = setInterval(() => {
const currentProgress = taskProgressRef.current;
setLocalLoading((prev) => {
if (prev >= currentProgress && currentProgress != 0) {
return currentProgress;
}
return prev + 0.1;
});
}, timeout);
try {
await uploadAndAnalyzeImage();
} catch (error) {
setIsPhotoCreating(false);
} finally {
clearInterval(timer);
setLocalLoading(0);
}
};
return (
<Drawer
open={isOpen}
placement="left"
width={isMobile ? "80%" : "40%"}
maskClosable={true}
closable={false}
onClose={onClose}
className="h5-photo-story-drawer [&_.ant-drawer-body]:!p-0 bg-white/[0.02]"
styles={{
body: {
height: "100svh",
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
},
}}
>
<GlobalLoad show={localLoading > 0} progress={localLoading}>
<div data-alt="drawer-content" className="flex flex-col h-[100svh] backdrop-blur-[16px]">
<div data-alt="drawer-header" className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div className="flex items-center gap-2">
<ImagePlay className="w-4 h-4 text-blue-400" />
<h2 className="text-medium font-bold text-white">Movie Generation from Image</h2>
</div>
</div>
<div data-alt="drawer-body" className="flex-1 overflow-y-auto">
<div className="p-4">
{/* 上传卡片 */}
<div className="w-full">
<div className="flex items-start gap-3">
<div className="shrink-0">
<div
data-alt="image-upload-area"
className={`w-28 h-28 md:w-32 md:h-32 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
activeImageUrl
? "border-2 border-white/20 bg-white/[0.05]"
: "border-2 border-dashed border-white/20 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05]"
}`}
onClick={handleImageUpload}
>
{activeImageUrl ? (
<div className="relative w-full h-full">
<img
src={activeImageUrl}
alt="Story inspiration"
className="w-full h-full object-contain rounded-lg bg-white/[0.05]"
/>
<Popconfirm
title="Clear all content"
description="Are you sure you want to clear all content? This action cannot be undone."
onConfirm={() => {
resetImageStory();
}}
okText="Yes"
cancelText="No"
showCancel={false}
okType="default"
placement="top"
classNames={{
root: "text-white event-pointer",
body: "text-white border rounded-lg bg-white/[0.04] [&_.ant-popconfirm-description]:!text-white [&_.ant-popconfirm-title]:!text-white [&_.ant-btn]:!text-white",
}}
>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="absolute top-1 right-1 w-5 h-5 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-md flex items-center justify-center text-white/90 border border-white/20 transition-all duration-200 z-10"
data-alt="clear-all-button"
aria-label="clear-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</Popconfirm>
</div>
) : (
<div className="text-center text-white/70">
<Upload className="w-6 h-6 mx-auto mb-1 opacity-60" />
<span className="text-xs">Upload</span>
</div>
)}
</div>
</div>
<div className="flex-1" />
</div>
</div>
{/* 人物头像网格 */}
{hasAnalyzed && avatarComputed.length > 0 && (
<div className="mt-4" data-alt="avatar-grid">
<div className="grid grid-cols-4 gap-3 md:grid-cols-4">
{avatarComputed.map((avatar, index) => (
<div key={`${avatar.name}-${index}`} className="flex flex-col items-center">
<div className="relative w-16 h-16 md:w-20 md:h-20 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group cursor-pointer">
<img
src={avatar.url}
alt={avatar.name}
className="w-full h-full object-contain bg-white/[0.05]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = activeImageUrl;
}}
/>
<Tooltip title="Remove this character from the movie" placement="top">
<button
onClick={(e) => {
e.stopPropagation();
setCharactersAnalysis((charactersAnalysis) => {
const updatedCharacters = charactersAnalysis.filter((char) => char.role_name !== avatar.name);
return updatedCharacters;
});
const updatedStory = storyContent
.replace(new RegExp(`<role[^>]*>${avatar.name}<\/role>`, "g"), "")
.replace(new RegExp(`\\b${avatar.name}\\b`, "g"), "")
.replace(/\s+/g, " ")
.trim();
updateStoryContent(updatedStory);
}}
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/40 border border-black/20 text-white rounded-full flex items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 z-10"
aria-label="remove-character"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</Tooltip>
<Tooltip title="Click to upload new image for this character" placement="top">
<button
onClick={(e) => {
e.stopPropagation();
uploadCharacterAvatarAndAnalyzeFeatures(avatar.name).catch(() => {});
}}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center"
aria-label="replace-character-avatar"
>
<Upload className="w-4 h-4 text-white" />
</button>
</Tooltip>
</div>
<input
type="text"
defaultValue={avatar.name}
onBlur={(e) => {
const newName = e.target.value.trim();
if (newName && newName !== avatar.name) {
updateCharacterName(avatar.name, newName);
}
}}
className="w-full max-w-[72px] md:max-w-[80px] text-center text-xs md:text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5"
/>
</div>
))}
</div>
</div>
)}
{/* 题材标签(自动换行,避免横向滚动) */}
{hasAnalyzed && potentialGenres.length > 0 && (
<div className="mt-4" data-alt="genre-tags">
<div className="flex flex-wrap gap-2">
{[...potentialGenres].map((genre) => (
<button
key={genre}
onClick={() => updateStoryType(genre)}
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 ${
selectedCategory === genre
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
}`}
aria-pressed={selectedCategory === genre}
>
{genre}
</button>
))}
</div>
</div>
)}
{/* 用户原始文本信息 */}
{originalUserDescription && (
<div className="mt-3 text-xs text-white/40 italic" data-alt="user-original-text">
Your Provided Text: {originalUserDescription}
</div>
)}
{/* 文本编辑器 */}
<div className="mt-3 relative" data-alt="editor-section">
<HighlightEditor
content={storyContent}
onContentChange={updateStoryContent}
onCursorPositionChange={handleCursorPositionChange}
cursorPosition={cursorPosition}
type={"role"}
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
className="pr-0"
/>
</div>
</div>
</div>
<div data-alt="bottom-action-bar" className="sticky bottom-0 left-0 right-0 backdrop-blur border-t border-white/10 px-3 py-2">
<div className="flex items-center justify-end">
{!hasAnalyzed ? (
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
<div>
<ActionButton
isCreating={isLoading || isPhotoCreating}
handleCreateVideo={handleAnalyzeImage}
icon={<Sparkles className="w-4 h-4" />}
disabled={isLoading || isPhotoCreating}
width="w-10"
height="h-10"
/>
</div>
</Tooltip>
) : (
<Tooltip title="Confirm story creation" placement="top">
<div>
<ActionButton
isCreating={isLoading}
handleCreateVideo={handleConfirm}
icon={<ImagePlay className="w-4 h-4" />}
disabled={isCreating}
width="w-10"
height="h-10"
/>
</div>
</Tooltip>
)}
</div>
</div>
</div>
</GlobalLoad>
</Drawer>
);
};
export default H5PhotoStoryDrawer;

View File

@ -0,0 +1,603 @@
"use client";
import { useEffect, useState } from "react";
import { Drawer, Tooltip, Upload, Image } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { Clapperboard, Sparkles, LayoutTemplate, ChevronDown, ChevronUp, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import { useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import GlobalLoad from "../common/GlobalLoad";
import { motion, AnimatePresence } from "framer-motion";
interface H5TemplateDrawerProps {
isMobile: boolean;
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;
};
}
export const H5TemplateDrawer = ({
isMobile,
isTemplateCreating,
setIsTemplateCreating,
isRoleGenerating,
setIsRoleGenerating,
isItemGenerating,
setIsItemGenerating,
isOpen,
onClose,
configOptions,
}: H5TemplateDrawerProps) => {
const router = useRouter();
const {
templateStoryList,
selectedTemplate,
isLoading,
getTemplateStoryList,
actionStory,
setSelectedTemplate,
AvatarAndAnalyzeFeatures,
updateItemImage,
handleRoleFieldBlur,
handleItemFieldBlur,
clearData,
} = useTemplateStoryServiceHook();
const { uploadFile } = useUploadFile();
const [localLoading, setLocalLoading] = useState(0);
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>({});
const [isBottomExpanded, setIsBottomExpanded] = useState(true);
const [isDescExpanded, setIsDescExpanded] = useState(false);
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 handleConfirm = async () => {
if (!selectedTemplate || isTemplateCreating) return;
setIsTemplateCreating(true);
let timer: NodeJS.Timeout | null = null;
try {
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) return;
timer = setInterval(() => {
setLocalLoading((prev) => (prev >= 95 ? 95 : 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);
}
} catch (error) {
setIsTemplateCreating(false);
setLocalLoading(0);
setSelectedTemplate(null);
} finally {
setLocalLoading(0);
if (timer) clearInterval(timer);
}
};
const renderTopTemplateList = () => {
return (
<div
data-alt="top-template-list"
className="w-full p-3 border-b border-white/10 overflow-y-auto"
>
<div className="flex flex-col gap-2">
{templateStoryList.map((template, index) => {
const isSelected = selectedTemplate?.id === template.id;
return (
<button
key={template.id}
data-alt={`template-row-${index}`}
onClick={() => {
if (!isBottomExpanded) setIsBottomExpanded(true);
setSelectedTemplate(template);
}}
className={`${isSelected ? "ring-1 ring-blue-500/60 bg-white/[0.06]" : "bg-white/0"} w-full flex items-center gap-3 rounded-xl border border-white/10 hover:border-white/20 hover:bg-white/[0.04] transition-colors p-2`}
>
<div data-alt="template-cover" className="w-16 h-20 rounded-lg overflow-hidden border border-white/10 flex-shrink-0">
<Image
src={template.image_url[0]}
alt={template.name}
className="w-full h-full object-cover"
preview={{ mask: null, maskClassName: "hidden" }}
/>
</div>
<div data-alt="template-info" className="flex-1 min-w-0 text-left">
<div className="flex items-start gap-2">
<h4 className="text-sm font-semibold text-white truncate">
{template.name}
</h4>
</div>
<span className="mt-1 text-xs text-white/60 leading-snug line-clamp-2">
{template.generateText}
</span>
</div>
<div className="ml-2 flex-shrink-0">
{isSelected ? (
<CheckCircle2 data-alt="template-selected-mark" className="w-5 h-5 text-blue-500" />
) : null}
</div>
</button>
);
})}
</div>
</div>
);
};
const renderRoles = () => {
if (!selectedTemplate?.storyRole || selectedTemplate.storyRole.length === 0) return null;
return (
<div data-alt="roles-section" className="pt-3 border-t border-white/10">
<h3 className="text-base font-semibold text-white mb-3">Character Configuration</h3>
<div className="grid grid-cols-3 gap-3">
{selectedTemplate.storyRole.map((role, index) => (
<div key={index} data-alt={`role-field-${index}`} className="flex flex-col items-center space-y-2">
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={role.role_description || ""}
onChange={(e) => {
const updatedTemplate: StoryTemplateEntity = {
...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-[20rem] px-3 py-2 pr-12 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">
<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="click"
styles={{ root: { zIndex: 1000 } }}
>
<div
data-alt={`role-thumbnail-${index}`}
className="w-20 h-20 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer"
>
<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="absolute -top-[0.5rem] left-0 right-0 flex justify-center gap-2 opacity-100">
<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"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
<Upload
name="roleImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) return false;
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) return false;
return true;
}}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(fileObj, () => {});
await AvatarAndAnalyzeFeatures(uploadedUrl, role.role_name);
onSuccess?.(uploadedUrl as any);
} catch (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"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
<div className="text-center mt-1">
<span className="text-white text-xs font-medium">{role.role_name}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};
const renderItems = () => {
if (!selectedTemplate?.storyItem || selectedTemplate.storyItem.length === 0) return null;
return (
<div data-alt="items-section" className="pt-3 border-t border-white/10">
<h3 className="text-base font-semibold text-white mb-3">props Configuration</h3>
<div className="grid grid-cols-3 gap-3">
{selectedTemplate.storyItem.map((item, index) => (
<div key={index} data-alt={`item-field-${index}`} className="flex flex-col items-center space-y-2">
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={item.item_description || ""}
onChange={(e) => {
const updatedTemplate: StoryTemplateEntity = {
...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-[20rem] px-3 py-2 pr-12 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">
<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="click"
styles={{ root: { zIndex: 1000 } }}
>
<div
data-alt={`item-thumbnail-${index}`}
className="w-20 h-20 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer"
>
<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="absolute -top-[0.5rem] left-0 right-0 flex justify-center gap-2 opacity-100">
<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"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
<Upload
name="itemImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) return false;
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) return false;
return true;
}}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(fileObj, () => {});
updateItemImage(item.item_name, uploadedUrl);
onSuccess?.(uploadedUrl as any);
} catch (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"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
<div className="text-center mt-1">
<span className="text-white text-xs font-medium">{item.item_name}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};
const renderBottomDetail = () => {
if (!selectedTemplate) {
return (
<div data-alt="no-template" className="flex items-center justify-center py-12">
<div className="text-center text-white/60">
<LayoutTemplate className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-base">No templates available</p>
<p className="text-xs">Please try again later</p>
</div>
</div>
);
}
return (
<div data-alt="selected-template-detail" className="w-full p-3 space-y-3">
<div className="flex items-start gap-3">
<div className="w-24 h-32 rounded-lg overflow-hidden border border-white/10">
<Image
src={selectedTemplate.image_url[0]}
alt={selectedTemplate.name}
className="w-full h-full object-cover"
preview={{ mask: null, maskClassName: "hidden" }}
/>
</div>
<div className="flex-1 min-w-0">
<h2 data-alt="template-title" className="text-lg font-bold text-white mb-1 truncate">
{selectedTemplate.name}
</h2>
<div data-alt="desc-container" className="relative">
<p
data-alt="template-description"
className={`text-gray-300 text-xs leading-relaxed ${isDescExpanded ? "" : "line-clamp-4"}`}
>
{selectedTemplate.generateText}
</p>
{!isDescExpanded && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-black/60 to-transparent" />
)}
</div>
<div className="mt-1">
<button
data-alt="desc-toggle"
onClick={() => setIsDescExpanded((v) => !v)}
className="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300"
>
<span>{isDescExpanded ? "Collapse" : "Expand"}</span>
{isDescExpanded ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
</div>
</div>
</div>
{renderRoles()}
{renderItems()}
<div className="w-full flex items-center justify-end gap-2">
{selectedTemplate?.freeInputItem && (
<div data-alt="free-input" className="flex-1">
<input
type="text"
value={selectedTemplate.freeInputItem.free_input_text || ""}
placeholder={selectedTemplate.freeInputItem.user_tips}
className="w-full px-3 py-2 pr-12 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!,
freeInputItem: {
...selectedTemplate!.freeInputItem,
free_input_text: e.target.value,
},
} as StoryTemplateEntity;
setSelectedTemplate(updatedTemplate);
}}
/>
</div>
)}
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-4 h-4" />}
disabled={isTemplateCreating || localLoading > 0}
width="w-10"
height="h-10"
/>
</div>
</div>
);
};
return (
<Drawer
open={isOpen}
placement="left"
width={isMobile ? "80%" : "40%"}
maskClosable={true}
closable={false}
onClose={() => {
clearData();
onClose();
}}
className="h5-template-drawer [&_.ant-drawer-body]:!p-0 bg-white/[0.02]"
styles={{
body: {
height: `calc(100vh - 3rem)`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
},
}}
>
<GlobalLoad show={isLoading} progress={localLoading}>
<div data-alt="drawer-content" className="flex flex-col h-[100svh]">
<div data-alt="drawer-header" className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h2 className="text-medium font-bold text-white">Template Story</h2>
</div>
<div data-alt="drawer-body" className="flex-1 overflow-y-auto">
<div data-alt="top-section" className="h-full overflow-y-auto">
{renderTopTemplateList()}
</div>
<motion.div
data-alt="bottom-section"
className={`absolute left-0 right-0 bottom-0 bg-white/[0.04] border-t border-white/10 rounded-t-xl shadow-[0_-8px_30px_rgba(0,0,0,0.35)] backdrop-blur-md z-30 overflow-visible`}
style={{ maxHeight: "90vh" }}
animate={{ height: isBottomExpanded ? "auto" : 48 }}
transition={{ type: "spring", stiffness: 220, damping: 26 }}
>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 z-50 backdrop-blur-md">
<motion.button
data-alt="toggle-bottom-section"
onClick={() => setIsBottomExpanded((v) => !v)}
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 border border-white/20 text-white flex items-center justify-center shadow-xl"
whileTap={{ scale: 0.96 }}
>
<motion.div animate={{ rotate: isBottomExpanded ? 0 : 180 }} transition={{ duration: 0.2 }}>
<ChevronDown className="w-5 h-5" />
</motion.div>
</motion.button>
</div>
<AnimatePresence initial={false}>
{isBottomExpanded && (
<motion.div
key="bottom-content"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="p-0 overflow-y-auto"
style={{ maxHeight: "90vh" }}
>
{renderBottomDetail()}
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</GlobalLoad>
</Drawer>
);
};
export default H5TemplateDrawer;

View File

@ -0,0 +1,349 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Modal, Tooltip, Popconfirm, Upload } from "antd";
import { ImagePlay, Sparkles, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import GlobalLoad from "../common/GlobalLoad";
import { ActionButton } from "../common/ActionButton";
import { HighlightEditor } from "../common/HighlightEditor";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
import { useLoadScriptText } from "@/app/service/domain/service";
type ConfigOptions = {
mode: "auto" | "manual";
resolution: "720p" | "1080p" | "4k";
language: string;
videoDuration: string;
};
export const PcPhotoStoryModal = ({
isCreating,
setIsCreating,
isPhotoCreating,
setIsPhotoCreating,
isOpen,
onClose,
configOptions = {
mode: "auto",
resolution: "720p",
language: "english",
videoDuration: "1min",
},
}: {
isOpen: boolean;
onClose: () => void;
isCreating: boolean;
setIsCreating: (value: boolean) => void;
isPhotoCreating: boolean;
setIsPhotoCreating: (value: boolean) => void;
configOptions?: ConfigOptions;
}) => {
const {
activeImageUrl,
storyContent,
potentialGenres,
selectedCategory,
isLoading,
hasAnalyzed,
taskProgress,
updateStoryType,
updateStoryContent,
updateCharacterName,
resetImageStory,
triggerFileSelection,
avatarComputed,
uploadAndAnalyzeImage,
setCharactersAnalysis,
originalUserDescription,
actionMovie,
uploadCharacterAvatarAndAnalyzeFeatures,
} = useImageStoryServiceHook();
const { loadingText } = useLoadScriptText(isLoading);
const [localLoading, setLocalLoading] = useState(0);
const router = useRouter();
const taskProgressRef = useRef(taskProgress);
const [cursorPosition, setCursorPosition] = useState(0);
const handleCursorPositionChange = (position: number) => {
setCursorPosition(position);
};
useEffect(() => {
taskProgressRef.current = taskProgress;
}, [taskProgress]);
const handleClose = () => {
onClose();
};
const handleImageUpload = async (e: any) => {
const target = e.target as HTMLImageElement;
if (!(target.tagName == "IMG" || e.target.dataset.alt == "image-upload-area")) {
return;
}
e.preventDefault();
e.stopPropagation();
try {
await triggerFileSelection();
} catch (error) {
console.error("Failed to upload image:", error);
}
};
const handleConfirm = async () => {
try {
setIsCreating(true);
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
console.error("用户未登录");
return;
}
const episodeResponse = await actionMovie(
String(User.id),
configOptions.mode as "auto" | "manual",
configOptions.resolution as "720p" | "1080p" | "4k",
configOptions.language
);
if (!episodeResponse) return;
const episodeId = episodeResponse.project_id;
router.push(`/movies/work-flow?episodeId=${episodeId}`);
handleClose();
} catch (error) {
setIsCreating(false);
console.error("创建电影项目失败:", error);
}
};
const handleAnalyzeImage = async () => {
if (isPhotoCreating || isLoading) return;
setIsPhotoCreating(true);
let timeout = 100;
let timer: NodeJS.Timeout;
timer = setInterval(() => {
const currentProgress = taskProgressRef.current;
setLocalLoading((prev) => {
if (prev >= currentProgress && currentProgress != 0) {
return currentProgress;
}
return prev + 0.1;
});
}, timeout);
try {
await uploadAndAnalyzeImage();
} catch (error) {
console.error("分析图片失败:", error);
setIsPhotoCreating(false);
} finally {
clearInterval(timer);
setLocalLoading(0);
}
};
return (
<Modal
open={isOpen}
onCancel={handleClose}
footer={null}
width="80%"
maskClosable={false}
style={{ maxWidth: "1000px", marginTop: "10vh" }}
className="photo-story-modal bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
closeIcon={
<div 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 flex items-center justify-center">×</span>
</div>
}
>
<GlobalLoad show={localLoading > 0} progress={localLoading}>
<div className="rounded-2xl">
<div className="flex items-center gap-3 p-2 border-b border-white/[0.1]">
<ImagePlay className="w-6 h-6 text-blue-400" />
<h2 className="text-xl font-bold text-white">Movie Generation from Image</h2>
</div>
<div className="w-full bg-white/[0.04] border border-white/[0.1] rounded-xl p-4 mt-2">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div
data-alt="image-upload-area"
className={`w-32 h-32 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
activeImageUrl
? "border-2 border-white/20 bg-white/[0.05]"
: "border-2 border-dashed border-white/20 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05] hover:scale-105"
}`}
onClick={handleImageUpload}
>
{activeImageUrl ? (
<div className="relative w-full h-full">
<img
src={activeImageUrl}
alt="Story inspiration"
className="w-full h-full object-contain rounded-lg bg-white/[0.05]"
/>
<Popconfirm
title="Clear all content"
description="Are you sure you want to clear all content? This action cannot be undone."
onConfirm={() => {
resetImageStory();
}}
okText="Yes"
cancelText="No"
showCancel={false}
okType="default"
placement="top"
classNames={{
root: "text-white event-pointer",
body: "text-white border rounded-lg bg-white/[0.04] [&_.ant-popconfirm-description]:!text-white [&_.ant-popconfirm-title]:!text-white [&_.ant-btn]:!text-white",
}}
>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="absolute top-1 right-1 w-4 h-4 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-md flex items-center justify-center text-white/90 hover:text-white border border-white/20 hover:border-white/30 transition-all duration-200 z-10 shadow-sm hover:shadow-md"
data-alt="clear-all-button"
>
<Trash2 className="w-3 h-3" />
</button>
</Popconfirm>
</div>
) : (
<div className="text-center text-white/60">
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
<p className="text-xs">Upload</p>
</div>
)}
</div>
</div>
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
{hasAnalyzed && avatarComputed.length > 0 && (
<div className="flex gap-2 n justify-start">
{avatarComputed.map((avatar, index) => (
<div key={`${avatar.name}-${index}`} className="flex flex-col items-center">
<div className="relative w-20 h-20 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group cursor-pointer">
<img
src={avatar.url}
alt={avatar.name}
className="w-full h-full object-contain bg-white/[0.05]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = activeImageUrl;
}}
/>
<Tooltip title="Remove this character from the movie" placement="top">
<button
onClick={(e) => {
e.stopPropagation();
setCharactersAnalysis((charactersAnalysis) => {
const updatedCharacters = charactersAnalysis.filter((char) => char.role_name !== avatar.name);
return updatedCharacters;
});
const updatedStory = storyContent
.replace(new RegExp(`<role[^>]*>${avatar.name}<\/role>`, "g"), "")
.replace(new RegExp(`\\b${avatar.name}\\b`, "g"), "")
.replace(/\s+/g, " ")
.trim();
updateStoryContent(updatedStory);
}}
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/[0.4] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</Tooltip>
<Tooltip title="Click to upload new image for this character" placement="top">
<button
onClick={(e) => {
e.stopPropagation();
uploadCharacterAvatarAndAnalyzeFeatures(avatar.name).catch((error) => {
console.error("上传人物头像失败:", error);
});
}}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center"
>
<Upload className="w-4 h-4 text-white" />
</button>
</Tooltip>
</div>
<div className="relative group">
<input
type="text"
defaultValue={avatar.name}
onBlur={(e) => {
const newName = e.target.value.trim();
if (newName && newName !== avatar.name) {
updateCharacterName(avatar.name, newName);
}
}}
className="w-16 text-center text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 transition-all duration-200"
style={{ textAlign: "center" }}
/>
<div className="absolute inset-0 border border-transparent group-hover:border-white/20 rounded transition-all duration-200 pointer-events-none"></div>
</div>
</div>
))}
</div>
)}
</div>
{hasAnalyzed && potentialGenres.length > 0 && (
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
<div className="flex gap-2">
{[...potentialGenres].map((genre) => (
<button
key={genre}
onClick={() => updateStoryType(genre)}
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 whitespace-nowrap ${
selectedCategory === genre
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
}`}
>
{genre}
</button>
))}
</div>
</div>
)}
</div>
{originalUserDescription && (
<div className="mt-2 text-sm text-white/30 italic">Your Provided Text:{originalUserDescription}</div>
)}
<div className="flex items-start gap-4 mt-2 relative">
<HighlightEditor
content={storyContent}
onContentChange={updateStoryContent}
onCursorPositionChange={handleCursorPositionChange}
cursorPosition={cursorPosition}
type={"role"}
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
/>
<div className="absolute bottom-1 right-0 flex gap-2">
{!hasAnalyzed ? (
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
<ActionButton
isCreating={isLoading || isPhotoCreating}
handleCreateVideo={handleAnalyzeImage}
icon={<Sparkles className="w-5 h-5" />}
disabled={isLoading || isPhotoCreating}
/>
</Tooltip>
) : (
<Tooltip title="Confirm story creation" placement="top">
<ActionButton
isCreating={isLoading}
handleCreateVideo={handleConfirm}
icon={<ImagePlay className="w-5 h-5" />}
disabled={isCreating}
/>
</Tooltip>
)}
</div>
</div>
</div>
</div>
</GlobalLoad>
</Modal>
);
};

View File

@ -0,0 +1,740 @@
"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();
// 防抖处理的输入更新函数
const debouncedUpdateInput = debounce((value: string) => {
// 过滤特殊字符
const sanitizedValue = value.replace(/[<>]/g, '');
// 更新输入值
if (!selectedTemplate?.freeInputItem) return;
const updatedTemplate: StoryTemplateEntity = {
...selectedTemplate,
freeInputItem: {
...selectedTemplate.freeInputItem,
free_input_text: sanitizedValue
}
};
setSelectedTemplate(updatedTemplate);
}, 300); // 300ms 的防抖延迟
// 使用上传文件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?.freeInputItem) && (
<div className="py-2 flex-1">
<input
type="text"
value={selectedTemplate?.freeInputItem?.free_input_text || ""}
placeholder={selectedTemplate?.freeInputItem.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!,
freeInputItem: {
...selectedTemplate!.freeInputItem,
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>
</>
);
};

View File

@ -37,7 +37,7 @@ export default function GlobalLoad({
const customIndicator = ( const customIndicator = (
<div <div
data-alt="custom-loading-indicator" data-alt="custom-loading-indicator"
className="!w-max !h-max !flex flex-col items-center justify-center gap-4 -translate-x-1/2 -translate-y-1/2" className="!w-max !h-max !flex flex-col items-center justify-center -translate-x-1/2"
> >
{showSpinner && <TailwindSpinner diameter={spinnerDiameter} />} {showSpinner && <TailwindSpinner diameter={spinnerDiameter} />}
{Boolean(progress) && <TailwindLinearLoader progress={progress as number} width={progressWidth} />} {Boolean(progress) && <TailwindLinearLoader progress={progress as number} width={progressWidth} />}

View File

@ -16,6 +16,7 @@ export const HighlightEditor = ({
placeholder, placeholder,
cursorPosition, cursorPosition,
onCursorPositionChange, onCursorPositionChange,
className
}: { }: {
/** 内容 */ /** 内容 */
content: string; content: string;
@ -29,6 +30,8 @@ export const HighlightEditor = ({
cursorPosition?: number; cursorPosition?: number;
/** 光标位置变化回调 */ /** 光标位置变化回调 */
onCursorPositionChange?: (position: number) => void; onCursorPositionChange?: (position: number) => void;
/** 样式 */
className?: string;
}) => { }) => {
console.log(44444); console.log(44444);
@ -120,7 +123,7 @@ export const HighlightEditor = ({
}, [content, editor, cursorPosition]); }, [content, editor, cursorPosition]);
return ( return (
<div className="flex-1 min-w-0 relative pr-20"> <div className={`flex-1 min-w-0 relative pr-20 ${className}`}>
<style jsx>{` <style jsx>{`
.${type}-name-highlight { .${type}-name-highlight {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

View File

@ -15,20 +15,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
// 根据设备类型设置布局样式 // 根据设备类型设置布局样式
const getLayoutStyles = () => { const getLayoutStyles = () => {
if (isMobile) { if (isMobile || isTablet) {
return { return {
left: '0', left: '0',
width: '100vw' width: '100vw'
}; };
} }
if (isTablet) {
return {
left: sidebarCollapsed ? '3rem' : '12rem',
width: sidebarCollapsed ? 'calc(100vw - 3rem)' : 'calc(100vw - 12rem)'
};
}
// 桌面端 // 桌面端
return { return {
left: sidebarCollapsed ? '4rem' : '16rem', left: sidebarCollapsed ? '4rem' : '16rem',

View File

@ -330,7 +330,7 @@
.auth-container { .auth-container {
/* max-width: 350px; */ /* max-width: 350px; */
margin-right: 0; margin-right: 0;
margin-left: auto; /* margin-left: auto; */
} }
.login-logo { .login-logo {
@ -339,30 +339,6 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
/* 移动端文字颜色调整 */
.auth-header h2 {
background: linear-gradient(135deg, #000, #333);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
color: #000;
}
.auth-header p {
color: rgba(0, 0, 0, 0.7);
}
.form-label {
color: rgba(0, 0, 0, 0.8);
}
.form-control {
color: #000;
}
.form-control::placeholder {
color: rgba(0, 0, 0, 0.4);
}
/* 注册链接改为橙色 */ /* 注册链接改为橙色 */
.auth-link { .auth-link {
color: #35daff; color: #35daff;
@ -381,11 +357,6 @@
color: #86efac !important; color: #86efac !important;
} }
/* 底部提示文字 */
.text-center p {
color: rgba(0, 0, 0, 0.6) !important;
}
/* 忘记密码链接也改为橙色 */ /* 忘记密码链接也改为橙色 */
.auth-link.small { .auth-link.small {
color: #35daff; color: #35daff;
@ -399,11 +370,6 @@
button[type="button"] span { button[type="button"] span {
color: white; color: white;
} }
/* 分割线文字 */
.text-gray-400 {
color: rgba(0, 0, 0, 0.5) !important;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@ -435,6 +401,20 @@
.auth-container { .auth-container {
padding: 20px 10px; padding: 20px 10px;
border-radius: 10px; border-radius: 10px;
width: calc(100% - 60px);
}
.auth-container form div {
margin-bottom: 0.5rem;
}
.auth-container button {
padding: 0.5rem;
font-size: 0.9rem;
}
.auth-container .text-center p {
font-size: 0.8rem;
} }
.login-logo { .login-logo {
@ -442,7 +422,7 @@
} }
.auth-header { .auth-header {
margin-bottom: 1.5rem; margin-bottom: 0.5rem;
} }
.auth-header h2 { .auth-header h2 {
@ -454,7 +434,8 @@
} }
.form-control { .form-control {
padding: 0.6rem 0.8rem; font-size: 0.8rem;
padding: 0.6rem;
} }
.btn-primary { .btn-primary {