video-flow-b/components/common/ChatInputBox.tsx

967 lines
33 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,
useRef,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import {
ChevronDown,
ChevronUp,
Video,
Loader2,
Lightbulb,
Package,
Crown,
Clapperboard,
Globe,
AudioLines,
Clock,
Trash2,
Plus,
LayoutTemplate,
ImagePlay,
} from "lucide-react";
import { Dropdown, Modal, Tooltip, Upload, Image } from "antd";
import { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons";
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
import TemplateCard from "./templateCard";
import { AudioRecorder } from "./AudioRecorder";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
// 自定义音频播放器样式
const customAudioPlayerStyles = `
.custom-audio-player {
background: rgba(255, 255, 255, 0.05) !important;
border-radius: 8px !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.custom-audio-player .rhap_main-controls-button {
color: white !important;
}
.custom-audio-player .rhap_progress-filled {
background-color: #3b82f6 !important;
}
.custom-audio-player .rhap_progress-indicator {
background-color: #3b82f6 !important;
}
.custom-audio-player .rhap_time {
color: rgba(255, 255, 255, 0.7) !important;
}
.custom-audio-player .rhap_volume-controls {
color: white !important;
}
/* 模式选择下拉菜单样式 */
.mode-dropdown .ant-dropdown-menu {
background: rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(20px) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
border-radius: 10px !important;
padding: 6px !important;
min-width: 160px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
}
.mode-dropdown .ant-dropdown-menu-item {
padding: 6px 10px !important;
border-radius: 6px !important;
color: rgba(255, 255, 255, 0.9) !important;
transition: all 0.2s ease !important;
margin-bottom: 3px !important;
}
.mode-dropdown .ant-dropdown-menu-item:hover {
background: rgba(255, 255, 255, 0.15) !important;
transform: translateX(4px) !important;
}
.mode-dropdown .ant-dropdown-menu-item:last-child {
margin-bottom: 0 !important;
}
/* 模式提示tooltip样式 */
.mode-tooltip .ant-tooltip-inner {
background: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 8px !important;
color: white !important;
font-size: 12px !important;
line-height: 1.4 !important;
max-width: 200px !important;
}
.mode-tooltip .ant-tooltip-arrow::before {
background: rgba(0, 0, 0, 0.8) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
/* 模板卡片样式 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 自定义滚动条 */
.template-list-scroll::-webkit-scrollbar {
width: 4px;
}
.template-list-scroll::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
}
.template-list-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.template-list-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 自定义缩放类 */
.scale-102 {
transform: scale(1.02);
}
`;
/**模板故事模式弹窗组件 */
const RenderTemplateStoryMode = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
// 使用 hook 管理状态
const {
templateStoryList,
selectedTemplate,
activeRoleIndex,
activeRole,
isLoading,
getTemplateStoryList,
actionStory,
setSelectedTemplate,
setActiveRoleIndex,
setActiveRoleImage,
setActiveRoleAudio,
} = useTemplateStoryServiceHook();
// 本地加载状态,用于 UI 反馈
const [localLoading, setLocalLoading] = useState(false);
// 组件挂载时获取模板列表
useEffect(() => {
if (isOpen) {
getTemplateStoryList();
}
}, [isOpen, getTemplateStoryList]);
// 处理模板选择
const handleTemplateSelect = (template: StoryTemplateEntity) => {
setSelectedTemplate(template);
setActiveRoleIndex(0); // 重置角色选择
};
// 处理确认操作
const handleConfirm = async () => {
if (!selectedTemplate) return;
try {
setLocalLoading(true);
const projectId = await actionStory();
console.log("Story action created:", projectId);
onClose();
// 重置状态
setSelectedTemplate(null);
setActiveRoleIndex(0);
} catch (error) {
console.error("Failed to create story action:", error);
// 这里可以添加 toast 提示
} finally {
setLocalLoading(false);
}
};
// 处理角色图片上传
const handleRoleImageUpload = (roleIndex: number, file: any) => {
if (file && selectedTemplate) {
// 模拟上传成功设置图片URL
const imageUrl = URL.createObjectURL(file);
setActiveRoleImage(imageUrl);
}
};
// 删除角色图片
const handleDeleteRoleImage = (roleIndex: number) => {
if (selectedTemplate) {
setActiveRoleImage("");
}
};
// 模板列表渲染
const templateListRender = () => {
return (
<div className="w-1/3 p-6 border-r border-white/[0.1]">
<h3 className="text-xl font-bold text-white mb-6">Story Templates</h3>
<div className="space-y-4 max-h-[700px] overflow-y-auto pr-3 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.imageUrl[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 isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
) : selectedTemplate ? (
<div>
{/* 模板信息头部 - 增加顶部空间 */}
<div className="flex gap-6 p-6 border-b border-white/[0.1]">
{/* 左侧图片 */}
<div className="w-1/3">
<div
data-alt="template-preview-image"
className="relative w-full aspect-square rounded-xl overflow-hidden group cursor-pointer"
>
<img
src={selectedTemplate.imageUrl[0]}
alt={selectedTemplate.name}
className="w-full h-full object-cover transition-all duration-500 group-hover:scale-105 group-hover:rotate-1"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
</div>
{/* 右侧信息 - 增加文本渲染空间 */}
<div className="flex-1 flex flex-col">
<h2
data-alt="template-title"
className="text-2xl font-bold text-white mb-6"
>
{selectedTemplate.name}
</h2>
<div className="flex-1 overflow-y-auto max-h-80 pr-2">
<p
data-alt="template-description"
className="text-gray-300 text-base leading-relaxed"
>
{selectedTemplate.generateText}
</p>
</div>
</div>
</div>
{/* 角色自定义部分 - 精简布局 */}
<div className="p-6">
<h3
data-alt="roles-section-title"
className="text-lg font-semibold text-white mb-4"
>
Character Customization
</h3>
{/* 紧凑布局 */}
<div className="mb-6 flex gap-4">
{/* 左侧:当前选中角色的音频与照片更改 - 精简版本 */}
<div className="flex-1 space-y-4">
{/* 图片上传部分 - 精简 */}
<div className="space-y-2 mb-8">
<div className="flex justify-center">
<Tooltip
title="Upload a portrait photo to replace this character's appearance in the movie."
placement="top"
>
<Upload
name="avatar"
listType="picture-card"
className="avatar-uploader [&_.ant-upload-select]:!w-32 [&_.ant-upload-select]:!h-32"
showUploadList={false}
action="https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload"
beforeUpload={() => false}
onChange={(info) => {
if (info.file.status === "done") {
handleRoleImageUpload(
activeRoleIndex,
info.file.originFileObj
);
}
}}
>
{activeRole?.photo_url ? (
<div className="relative w-32 h-32 rounded-lg overflow-hidden">
<img
src={activeRole.photo_url}
alt="Character Portrait"
className="w-full h-full object-cover"
/>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteRoleImage(activeRoleIndex);
}}
className="absolute top-1 right-1 w-5 h-5 bg-red-500/80 hover:bg-red-500 text-white rounded-full flex items-center justify-center transition-colors"
title="Delete Photo"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
<div className="text-white text-center">
<UploadOutlined className="w-4 h-4 mb-1" />
<div className="text-xs">Change Photo</div>
</div>
</div>
</div>
) : (
<div className="w-32 h-32 flex flex-col items-center justify-center text-white/50 bg-white/[0.05] border border-white/[0.1] rounded-lg hover:bg-white/[0.08] transition-colors">
<UploadOutlined className="w-6 h-6 mb-1" />
<span className="text-xs">Upload Photo</span>
</div>
)}
</Upload>
</Tooltip>
</div>
</div>
{/* 音频部分 - 精简版本 */}
<div className="space-y-2">
{/* 音频操作区域 - 使用新的 AudioRecorder 组件 */}
<div className="space-y-2">
<AudioRecorder
audioUrl={activeRole?.voice_url || ""}
onAudioRecorded={(audioBlob, audioUrl) => {
setActiveRoleAudio(audioUrl);
}}
onAudioDeleted={() => {
setActiveRoleAudio("");
}}
/>
</div>
</div>
</div>
{/* 右侧:角色图片缩略图列表 - 精简 */}
<div className="w-24 space-y-2">
<h4 className="text-white/70 text-xs font-medium mb-2">
Characters
</h4>
{selectedTemplate.storyRole.map((role, index: number) => (
<Tooltip key={index} title={role.role_name} placement="left">
<button
data-alt={`character-thumbnail-${index}`}
className={`w-full aspect-square rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-105 ${
activeRoleIndex === index
? "border-blue-500 shadow-lg shadow-blue-500/30"
: "border-white/20 hover:border-white/40"
}`}
onClick={() => setActiveRoleIndex(index)}
>
<img
src={role.photo_url}
alt={role.role_name}
className="w-full h-full object-cover"
/>
</button>
</Tooltip>
))}
</div>
</div>
</div>
{/* 弹窗底部操作 - 只保留 Action 按钮 */}
<div className="relative group flex justify-end mt-10">
<div className="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
<button
name="text"
className="input font-semibold text-base h-full opacity-90 w-full px-10 py-2 rounded-xl bg-black flex items-center justify-center"
onClick={handleConfirm}
>
{localLoading ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Actioning...
</>
) : (
<>
<Clapperboard className="w-5 h-5" />
Action
</>
)}
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
</div>
</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 (
<>
<style>{customAudioPlayerStyles}</style>
<Modal
open={isOpen}
onCancel={() => {
// 清空所有选中的内容数据
setSelectedTemplate(null);
setActiveRoleIndex(0);
onClose();
}}
footer={null}
width="60%"
style={{ maxWidth: "800px" }}
className="template-modal"
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>
}
>
<div className="rounded-2xl">
{/* 弹窗头部 */}
<div className="flex gap-4 p-4 border-b border-white/[0.1]">
<h2 className="text-2xl font-bold text-white">
Template Story Selection
</h2>
</div>
<div className="flex gap-4">
{templateListRender()}
<div className="flex-1">{storyEditorRender()}</div>
</div>
</div>
</Modal>
</>
);
};
/**
* 视频工具面板组件
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
*/
export function ChatInputBox() {
// 控制面板展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
// 模板故事弹窗状态
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
// 图片故事模式状态
const [isPhotoStoryMode, setIsPhotoStoryMode] = useState(false);
// 共享状态 - 需要在不同渲染函数间共享
const [script, setScript] = useState(""); // 用户输入的脚本内容
// 子组件引用,用于调用子组件方法
const photoStoryModeRef = useRef<{
enterPhotoStoryMode: () => void;
getStoryContent: () => string;
}>(null);
// 响应式管理输入框内容:当在图片故事模式下,从子组件获取故事内容
useEffect(() => {
if (isPhotoStoryMode && photoStoryModeRef.current) {
const storyContent = photoStoryModeRef.current.getStoryContent();
if (storyContent) {
setScript(storyContent);
}
}
}, [isPhotoStoryMode]);
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
// 配置选项状态 - 整合所有配置项到一个对象
const [configOptions, setConfigOptions] = useState({
mode: "auto",
resolution: "720p",
language: "english",
videoDuration: "1min",
});
// 退出图片故事模式
const exitPhotoStoryMode = () => {
setIsPhotoStoryMode(false);
};
const handleGetIdea = () => {
if (loadingIdea) return;
setLoadingIdea(true);
const ideaText =
"a cute capybara with an orange on its head, staring into the distance and walking forward";
setTimeout(() => {
setScript(ideaText);
setLoadingIdea(false);
}, 3000);
};
// Handle editor content changes
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
const newText = e.currentTarget.textContent || "";
setScript(newText);
};
// 当进入图片故事模式时,调用子组件方法
const enterPhotoStoryMode = () => {
setIsPhotoStoryMode(true);
// 调用子组件的进入方法
if (photoStoryModeRef.current) {
photoStoryModeRef.current.enterPhotoStoryMode();
}
};
// Handle creating video
const handleCreateVideo = async () => {
setIsCreating(true);
// 这里可以添加实际的创建逻辑
console.log("创建视频:", {
script,
...configOptions,
...(isPhotoStoryMode && {
// 从子组件获取图片故事相关信息
isPhotoStoryMode: true,
}),
});
setTimeout(() => {
setIsCreating(false);
}, 2000);
};
return (
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
<div className="video-storyboard-tools rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
{/* 展开/收起控制区域 */}
{isExpanded ? (
// 展开状态:显示收起按钮和提示
<div
className="absolute top-0 bottom-0 left-0 right-0 z-[1] flex flex-col items-center justify-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer"
onClick={() => setIsExpanded(false)}
>
<ChevronUp className="w-4 h-4 text-white/80" />
<span className="text-sm text-white/80 mt-1">Click to action</span>
</div>
) : (
// 收起状态:显示展开按钮
<div
className="absolute top-[-8px] left-[50%] z-[2] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]"
onClick={() => setIsExpanded(true)}
>
<ChevronDown className="w-4 h-4" />
</div>
)}
{/* 主要内容区域 - 简化层级,垂直居中 */}
<div
className={`flex items-center justify-center p-1 transition-all duration-300 ${
isExpanded ? "h-[16px]" : "h-[60px]"
}`}
>
{/* 输入框和Action按钮 - 只在展开状态显示 */}
{!isExpanded && (
<div className="flex items-center gap-3 w-full pl-3">
{/* 模式选择下拉菜单 */}
<Dropdown
menu={{
items: [
{
key: "template",
label: (
<div className="flex items-center gap-3 px-3 py-2">
<LayoutTemplate className="w-5 h-5 text-white/80" />
<span className="text-sm text-white/90">
Template Story
</span>
</div>
),
},
{
key: "photo",
label: (
<div className="flex items-center gap-3 px-3 py-2">
<ImagePlay className="w-5 h-5 text-white/80" />
<span className="text-sm text-white/90">
Photo Story
</span>
</div>
),
},
],
onClick: ({ key }) => {
if (key === "template") {
setIsTemplateModalOpen(true);
} else if (key === "photo") {
enterPhotoStoryMode();
}
console.log("Selected mode:", key);
},
}}
trigger={["click"]}
placement="topLeft"
overlayClassName="mode-dropdown"
>
<button
data-alt="mode-selector"
className="flex items-center justify-center w-10 h-10 bg-white/[0.08] backdrop-blur-xl border border-white/[0.12] rounded-lg text-white/80 hover:bg-white/[0.12] hover:border-white/[0.2] transition-all duration-200 shadow-[0_4px_16px_rgba(0,0,0,0.2)]"
>
<Plus className="w-4 h-4" />
</button>
</Dropdown>
{/* 图片故事模式UI - 始终渲染通过ref调用方法 */}
<PhotoStoryMode
ref={photoStoryModeRef}
onExitMode={exitPhotoStoryMode}
isVisible={isPhotoStoryMode}
/>
<div className="video-prompt-editor relative flex flex-1 items-center rounded-[6px]">
{/* 可编辑的脚本输入区域 */}
<div
className="editor-content flex-1 w-0 max-h-[48px] min-h-[40px] h-auto pl-[10px] pr-[10px] py-[10px] rounded-[10px] leading-[20px] text-sm border-none overflow-y-auto cursor-text flex items-center"
contentEditable={true}
onInput={handleEditorChange}
suppressContentEditableWarning
>
{script}
</div>
{/* 占位符文本和获取创意按钮 */}
<div
className={`custom-placeholder absolute top-[50%] left-[10px] z-10 translate-y-[-50%] flex items-center gap-1 pointer-events-none text-[14px] leading-[20px] text-white/[0.40] ${
script ? "hidden" : "block"
}`}
>
<span>Describe the content you want to action. Get an </span>
<b
className="idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline"
onClick={() => handleGetIdea()}
>
{loadingIdea ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Lightbulb className="w-4 h-4" />
idea
</>
)}
</b>
</div>
</div>
{/* Action按钮 */}
<div className="relative group">
<div className="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
<button
name="text"
className="input font-semibold text-base h-full opacity-90 w-full px-10 py-2 rounded-xl bg-black flex items-center justify-center"
onClick={isCreating ? undefined : handleCreateVideo}
>
{isCreating ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Actioning...
</>
) : (
<>
<Clapperboard className="w-5 h-5" />
Action
</>
)}
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
</div>
</div>
</div>
)}
</div>
</div>
{/* 配置选项区域 */}
<ConfigOptions
config={configOptions}
onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value }))
}
/>
{/* 模板故事弹窗 */}
<RenderTemplateStoryMode
isOpen={isTemplateModalOpen}
onClose={() => setIsTemplateModalOpen(false)}
/>
</div>
);
}
/**
* 配置选项组件
* 提供视频创建的各种配置选项,位于输入框下方
*/
const ConfigOptions = ({
config,
onConfigChange,
}: {
config: {
mode: string;
resolution: string;
language: string;
videoDuration: string;
};
onConfigChange: (key: string, value: string) => void;
}) => {
const configItems = [
{
key: "mode",
icon: Package,
options: [
{ value: "auto", label: "Auto", isVip: false },
{ value: "manual", label: "Manual", isVip: true },
],
},
{
key: "resolution",
icon: Video,
options: [
{ value: "720p", label: "720P", isVip: false },
{ value: "1080p", label: "1080P", isVip: true },
{ value: "2k", label: "2K", isVip: true },
{ value: "4k", label: "4K", isVip: true },
],
},
{
key: "language",
icon: Globe,
options: [
{ value: "english", label: "English", isVip: false },
{ value: "chinese", label: "Chinese", isVip: true },
{ value: "japanese", label: "Japanese", isVip: true },
{ value: "korean", label: "Korean", isVip: true },
],
},
{
key: "videoDuration",
icon: Clock,
options: [
{ value: "1min", label: "1 Min", isVip: false },
{ value: "2min", label: "2 Min", isVip: true },
{ value: "3min", label: "3 Min", isVip: true },
],
},
];
return (
<div className="flex items-center gap-3 mt-3">
{configItems.map((item) => {
const IconComponent = item.icon;
const currentOption = item.options.find(
(opt) => opt.value === config[item.key as keyof typeof config]
);
return (
<Dropdown
key={item.key}
menu={{
items: item.options.map((option) => ({
key: option.value,
label: (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-sm text-white">{option.label}</span>
{option.isVip && (
<Crown className="w-4 h-4 text-yellow-500" />
)}
</div>
),
})),
onClick: ({ key }) => onConfigChange(item.key, key),
}}
trigger={["click"]}
placement="bottomLeft"
>
<button
data-alt={`config-${item.key}`}
className="flex items-center gap-2 px-2.5 py-1.5 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200"
>
<IconComponent className="w-3.5 h-3.5" />
<span className="text-xs">{currentOption?.label}</span>
{currentOption?.isVip && (
<Crown className="w-2.5 h-2.5 text-yellow-500" />
)}
</button>
</Dropdown>
);
})}
</div>
);
};
/**
* 图片故事模式组件
* 显示图片预览、分析状态和故事类型选择器
* 使用自己的hook管理状态通过ref暴露方法给父组件
*/
const PhotoStoryMode = forwardRef<
{
enterPhotoStoryMode: () => void;
getStoryContent: () => string;
},
{
onExitMode: () => void;
isVisible: boolean;
}
>(({ onExitMode, isVisible }, ref) => {
// 使用图片故事服务hook管理自己的状态
const {
activeImageUrl,
selectedCategory,
isAnalyzing,
isUploading,
storyTypeOptions,
analyzedStoryContent,
updateStoryType,
resetImageStory,
updateStoryContent,
triggerFileSelectionAndAnalyze,
} = useImageStoryServiceHook();
// 通过ref暴露方法给父组件
useImperativeHandle(
ref,
() => ({
enterPhotoStoryMode: () => {
// 触发文件选择和分析
triggerFileSelectionAndAnalyze().catch((error: unknown) => {
console.error("Failed to enter photo story mode:", error);
});
},
getStoryContent: () => analyzedStoryContent || "",
}),
[triggerFileSelectionAndAnalyze, analyzedStoryContent]
);
// 处理退出模式
const handleExitMode = () => {
resetImageStory();
onExitMode();
};
// 如果不显示返回null
if (!isVisible) {
return null;
}
return (
<div className="absolute top-[-110px] left-14 right-0 flex items-center justify-between">
{/* 左侧:图片预览区域和分析状态指示器 */}
<div className="flex items-center gap-3">
{/* 图片预览区域 - 使用Ant Design Image组件 */}
{activeImageUrl && (
<div className="relative w-24 h-24 rounded-lg overflow-hidden bg-white/[0.05] border border-white/[0.1] shadow-[0_4px_16px_rgba(0,0,0,0.2)]">
<Image
src={activeImageUrl}
alt="Story inspiration"
className="w-full h-full object-cover"
preview={{
mask: <EyeOutlined className="w-6 h-6 text-white/80" />,
maskClassName:
"flex items-center justify-center bg-black/50 hover:bg-black/70 transition-colors",
}}
/>
</div>
)}
{/* 删除图片按钮 - 简洁样式 */}
{activeImageUrl && (
<button
onClick={handleExitMode}
className="absolute -top-2 left-24 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-all duration-200 z-10"
title="删除图片并退出图片故事模式"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
)}
{/* 分析状态指示器 */}
{isAnalyzing && (
<div className="flex items-center gap-2 px-3 py-2 bg-white/[0.1] rounded-lg">
<Loader2 className="w-4 h-4 animate-spin text-white/80" />
<span className="text-sm text-white/80">
{isUploading ? "Uploading image..." : "Analyzing image..."}
</span>
</div>
)}
</div>
{/* 右侧:故事类型选择器 */}
{activeImageUrl && (
<Dropdown
menu={{
items: storyTypeOptions.map((type) => ({
key: type.key,
label: (
<div className="px-3 py-2 text-sm text-white/90">
{type.label}
</div>
),
})),
onClick: ({ key }) => updateStoryType(key),
}}
trigger={["click"]}
placement="bottomRight"
>
<button className="px-3 py-2 bg-white/[0.1] hover:bg-white/[0.15] border border-white/[0.2] rounded-lg text-white/80 text-sm transition-colors flex items-center gap-2">
<span>
{storyTypeOptions.find((t) => t.key === selectedCategory)
?.label || "Auto"}
</span>
<ChevronDown className="w-3 h-3" />
</button>
</Dropdown>
)}
</div>
);
});