diff --git a/README.md b/README.md index c9b93f6..6e41111 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,99 @@ -安装依赖 +# Video Flow +一个基于Next.js的视频创作工具,支持多种创作模式和故事模板。 -```shellscript -npm install +## 故事模板组件 + +### 功能特性 + +故事模板交互组件 (`renderTemplateStoryMode`) 提供了以下功能: + +#### 1. 模板列表显示 +- 从 `StoryTemplateEntity` API 请求模板数据 +- 以横向滚动图标列表形式展示多个模板选项 +- 每个模板显示预览图片和名称 +- 支持加载状态和错误处理 + +#### 2. 模板详情弹窗 +- 用户点击模板图标后弹出模态框 +- 弹窗宽度为80%,居中显示 +- 顶部布局: + - 左侧:大图片预览(40%宽度) + - 右侧:故事模板名称和提示词描述 +- 图片支持鼠标悬停动画效果(轻微缩放和旋转) + +#### 3. 角色自定义功能 +- 显示可演绎的角色列表(基于模板数据) +- 每个角色支持: + - 上传图片替换默认角色图像 + - 录制音频功能 + - 上传音频文件功能 +- 交互式按钮设计,支持悬停效果 + +#### 4. 确认操作 +- 弹窗底部提供"取消"和"确定"按钮 +- 确定按钮执行空函数(可后续替换为实际API调用) +- 支持点击遮罩层关闭弹窗 + +### 技术实现 + +#### 状态管理 +```typescript +const [templates, setTemplates] = useState([]); +const [selectedTemplate, setSelectedTemplate] = useState(null); +const [isModalOpen, setIsModalOpen] = useState(false); +const [loading, setLoading] = useState(false); ``` +#### 核心功能 +- **模板数据获取**: 模拟API调用,支持异步加载 +- **模板选择**: 点击模板图标打开详情弹窗 +- **资源上传**: 支持图片和音频文件上传 +- **音频录制**: 预留音频录制接口 +- **响应式设计**: 使用Tailwind CSS实现现代UI + +#### 样式特点 +- 遵循现有组件设计风格 +- 使用毛玻璃效果和渐变背景 +- 支持悬停动画和过渡效果 +- 响应式布局,适配不同屏幕尺寸 + +### 使用方法 + +1. 在 `ChatInputBox` 组件中切换到 "template" 标签页 +2. 浏览横向滚动的模板列表 +3. 点击感兴趣的模板图标 +4. 在弹窗中查看模板详情和自定义角色 +5. 上传角色图片和音频资源 +6. 点击确定完成模板选择 + +### 数据结构 + +组件使用 `StoryTemplateEntity` 接口定义模板数据结构: + +```typescript +interface StoryTemplateEntity { + readonly id: string; + name: string; + imageUrl: string; + generateText: string; + storyRole: string[]; + userResources: { + role_name: string; + photo_url: string; + voice_url: string; + }[]; +} +``` + +### 扩展建议 + +- 集成真实的API接口替换模拟数据 +- 添加音频预览播放器功能 +- 实现上传进度条和状态提示 +- 支持模板收藏和最近使用功能 +- 添加模板搜索和分类筛选 + ## 启动开发服务器 运行以下命令启动开发服务器: @@ -29,4 +118,3 @@ ssh 77media@182.92.218.171 11) Deploy Vedio Flow Frontend Please select a service to deploy (0-10): 11 ``` - \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 2f78571..cd01797 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import './globals.css'; import type { Metadata } from 'next'; import { Providers } from '@/components/providers'; +import { ConfigProvider, theme } from 'antd'; export const metadata: Metadata = { title: 'AI Movie Flow - Create Amazing Videos with AI', @@ -15,10 +16,23 @@ export default function RootLayout({ return ( + {children} + ); -} \ No newline at end of file +} diff --git a/app/model/enums.ts b/app/model/enums.ts index 8d331d5..6de2e5c 100644 --- a/app/model/enums.ts +++ b/app/model/enums.ts @@ -18,6 +18,13 @@ export enum ResolutionEnum { UHD_4K = '4k', // "4k" } +// 视频时长枚举 +export enum VideoDurationEnum { + ONE_MINUTE = '1min', // "一分钟" + TWO_MINUTES = '2min', // "两分钟" + THREE_MINUTES = '3min', // "三分钟" +} + // 工作流阶段枚举 export enum FlowStageEnum { PREVIEW = 1, // "预览图列表分镜草图" @@ -45,7 +52,7 @@ export const ProjectTypeMap = { tab: "script" }, [ProjectTypeEnum.VIDEO_TO_VIDEO]: { - value: "video_to_video", + value: "video_to_video", label: "clone", tab: "clone" } @@ -83,6 +90,22 @@ export const ResolutionMap = { } } as const; +// 视频时长映射 +export const VideoDurationMap = { + [VideoDurationEnum.ONE_MINUTE]: { + value: "1min", + label: "One Minute" + }, + [VideoDurationEnum.TWO_MINUTES]: { + value: "2min", + label: "Two Minutes" + }, + [VideoDurationEnum.THREE_MINUTES]: { + value: "3min", + label: "Three Minutes" + } +} as const; + // 工作流阶段映射 export const FlowStageMap = { [FlowStageEnum.PREVIEW]: { @@ -133,7 +156,7 @@ export const TaskStatusMap = { value: "failed", label: "失败" } -} as const; +} as const; // 分镜脚本编辑器类型定义 export interface StoryboardCard { @@ -223,4 +246,4 @@ export const characterInfoMap: Record = { export const sceneInfoMap: Record = { '暮色森林': { image: 'https://c.huiying.video/images/7fd3f2d6-840a-46ac-a97d-d3d1b37ec4e0.jpg', location: '西境边陲', time: '傍晚' }, -}; \ No newline at end of file +}; diff --git a/app/service/domain/Entities.ts b/app/service/domain/Entities.ts index a1b6920..c6977cf 100644 --- a/app/service/domain/Entities.ts +++ b/app/service/domain/Entities.ts @@ -81,19 +81,6 @@ export interface VideoSegmentEntity { lens: LensType[]; } -/** - * 图片故事类型枚举 - * @description 标识图片故事的来源类型 - */ -export enum ImageStoryType { - /** 空文案自动生成故事 */ - autoStory = "autoStory", - /** 用户描述生成故事 */ - userStory = "userStory", - /** 模板故事 */ - templateStory = "templateStory", -} - /** * 图片故事实体接口 * @description 表示一条图片故事及其相关信息 @@ -103,29 +90,37 @@ export interface ImageStoryEntity { readonly id: string; /** 图片URL */ imageUrl: string; - /** 图片描述 */ - imageDescription: string; /** 图片故事内容 */ imageStory: string; /** 图片故事剧本 */ imageScript: string; /** 故事涉及的角色 */ storyRole: RoleEntity[]; - /** 故事类型 */ - storyType: ImageStoryType; + /** 故事分类 */ + storyType: string; } - +/** + * 故事模板实体接口 + * @description 表示一个故事模板及其相关信息 + */ export interface StoryTemplateEntity { /** 唯一标识 */ readonly id: string; /** 故事模板名称 */ name: string; - /** 故事模板描述 */ - description: string; /** 故事模板图片 */ imageUrl: string; /** 故事模板提示词 */ generateText: string; /**故事角色 */ - storyRole: RoleEntity[]; + storyRole: string[]; + /**用户自定义演绎资源 */ + userResources: { + /**对应角色名 */ + role_name: string; + /**照片URL */ + photo_url: string; + /**声音URL */ + voice_url: string; + }[]; } diff --git a/components/common/ChatInputBox.tsx b/components/common/ChatInputBox.tsx new file mode 100644 index 0000000..9f01c3f --- /dev/null +++ b/components/common/ChatInputBox.tsx @@ -0,0 +1,1527 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { + ChevronDown, + ChevronUp, + Video, + Loader2, + Lightbulb, + Package, + Crown, + ArrowUp, + Globe, + AudioLines, + Info, + Clock, + Trash2 +} from "lucide-react"; +import { Dropdown, Modal, Tooltip, Upload } from "antd"; +import { PlusOutlined, UploadOutlined } from "@ant-design/icons"; +import type { MenuProps } from "antd"; +import { ModeEnum, ResolutionEnum, VideoDurationEnum } from "@/app/model/enums"; +import { + StoryTemplateEntity, + ImageStoryEntity, +} from "@/app/service/domain/Entities"; +import AudioPlayer from "react-h5-audio-player"; +import "react-h5-audio-player/lib/styles.css"; + +// 自定义音频播放器样式 +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; + } +`; + +/**模板故事模式 */ +const RenderTemplateStoryMode = () => { + const [templates, setTemplates] = useState([]); + const [selectedTemplate, setSelectedTemplate] = + useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [loading, setLoading] = useState(false); + // 角色资源状态管理 + const [roleImages, setRoleImages] = useState<{ [key: number]: string }>({}); + const [roleAudios, setRoleAudios] = useState<{ [key: number]: string }>({}); + const [selectedRoleIndex, setSelectedRoleIndex] = useState(0); + // 模拟API请求获取模板数据 + const fetchTemplates = async () => { + setLoading(true); + try { + // 模拟API调用,实际项目中替换为真实API + const mockTemplates: StoryTemplateEntity[] = [ + { + id: "1", + name: "Three Little Dwarves", + imageUrl: "https://picsum.photos/200/200?random=1", + generateText: + "A story about courage and friendship, where the protagonist faces numerous challenges...", + storyRole: ["Brave Warrior", "Wise Mage", "Loyal Companion"], + userResources: [], + }, + { + id: "2", + name: "Seven Old Women", + imageUrl: "https://picsum.photos/200/200?random=2", + generateText: + "In a futuristic world where technology and humanity intertwine, exploring the unknown mysteries of the universe...", + storyRole: ["Space Explorer", "AI Assistant", "Alien Creature"], + userResources: [], + }, + { + id: "3", + name: "Nine Wooden Men", + imageUrl: "https://picsum.photos/200/200?random=3", + generateText: + "A magical fairy tale world where good battles evil, leading to a beautiful ending...", + storyRole: ["Princess", "Prince", "Magician"], + userResources: [], + }, + ]; + setTemplates(mockTemplates); + } catch (error) { + console.error("Failed to fetch templates:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTemplates(); + }, []); + + // 处理模板选择 + const handleTemplateSelect = (template: StoryTemplateEntity) => { + setSelectedTemplate(template); + setIsModalOpen(true); + }; + + // 处理确认操作 + const handleConfirm = () => { + // 这里可以添加实际的API调用逻辑 + console.log("Template confirmed:", selectedTemplate); + setIsModalOpen(false); + setSelectedTemplate(null); + }; + + // 处理角色图片上传 + const handleRoleImageUpload = (roleIndex: number, file: any) => { + if (file && selectedTemplate) { + // 这里可以添加实际的上传逻辑 + console.log("Character image uploaded:", file.name); + // 模拟上传成功,设置图片URL + const imageUrl = URL.createObjectURL(file); + setRoleImages((prev) => ({ ...prev, [roleIndex]: imageUrl })); + + // 保存到模板资源中 + const roleName = selectedTemplate.storyRole[roleIndex]; + + // 查找是否已存在该角色的资源记录 + const existingResourceIndex = selectedTemplate.userResources.findIndex( + (resource) => resource.role_name === roleName + ); + + let updatedTemplate; + if (existingResourceIndex >= 0) { + // 如果已存在,更新现有记录 + const updatedResources = [...selectedTemplate.userResources]; + updatedResources[existingResourceIndex] = { + ...updatedResources[existingResourceIndex], + photo_url: imageUrl, + }; + updatedTemplate = { + ...selectedTemplate, + userResources: updatedResources, + }; + } else { + // 如果不存在,创建新记录 + updatedTemplate = { + ...selectedTemplate, + userResources: [ + ...selectedTemplate.userResources, + { + role_name: roleName, + photo_url: imageUrl, + voice_url: "", + }, + ], + }; + } + + setSelectedTemplate(updatedTemplate); + } + }; + + // 删除角色图片 + const handleDeleteRoleImage = (roleIndex: number) => { + setRoleImages((prev) => { + const newState = { ...prev }; + delete newState[roleIndex]; + return newState; + }); + + // 同时从模板资源中删除图片 + if (selectedTemplate) { + const roleName = selectedTemplate.storyRole[roleIndex]; + const existingResourceIndex = selectedTemplate.userResources.findIndex( + (resource) => resource.role_name === roleName + ); + + if (existingResourceIndex >= 0) { + const updatedResources = [...selectedTemplate.userResources]; + updatedResources[existingResourceIndex] = { + ...updatedResources[existingResourceIndex], + photo_url: "", + }; + + const updatedTemplate = { + ...selectedTemplate, + userResources: updatedResources, + }; + setSelectedTemplate(updatedTemplate); + } + } + }; + + // 处理音频录制 + const handleAudioRecord = async (roleIndex: number) => { + try { + // 请求麦克风权限 + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // 创建 MediaRecorder + const mediaRecorder = new MediaRecorder(stream); + const audioChunks: Blob[] = []; + + mediaRecorder.ondataavailable = (event) => { + audioChunks.push(event.data); + }; + + mediaRecorder.onstop = () => { + // 停止所有音轨 + stream.getTracks().forEach((track) => track.stop()); + + // 创建音频 Blob + const audioBlob = new Blob(audioChunks, { type: "audio/wav" }); + const audioUrl = URL.createObjectURL(audioBlob); + + // 保存到状态 + setRoleAudios((prev) => ({ ...prev, [roleIndex]: audioUrl })); + + // 保存到模板资源中 + if (selectedTemplate) { + const roleName = selectedTemplate.storyRole[roleIndex]; + + // 查找是否已存在该角色的资源记录 + const existingResourceIndex = + selectedTemplate.userResources.findIndex( + (resource) => resource.role_name === roleName + ); + + let updatedTemplate; + if (existingResourceIndex >= 0) { + // 如果已存在,更新现有记录 + const updatedResources = [...selectedTemplate.userResources]; + updatedResources[existingResourceIndex] = { + ...updatedResources[existingResourceIndex], + voice_url: audioUrl, + }; + updatedTemplate = { + ...selectedTemplate, + userResources: updatedResources, + }; + } else { + // 如果不存在,创建新记录 + updatedTemplate = { + ...selectedTemplate, + userResources: [ + ...selectedTemplate.userResources, + { + role_name: roleName, + photo_url: "", + voice_url: audioUrl, + }, + ], + }; + } + + setSelectedTemplate(updatedTemplate); + } + }; + + // 开始录制 + mediaRecorder.start(); + + // 显示录制状态(可以添加一个录制指示器) + console.log("Started recording audio for role:", roleIndex); + + // 5秒后自动停止录制(或者可以添加手动停止按钮) + setTimeout(() => { + if (mediaRecorder.state === "recording") { + mediaRecorder.stop(); + } + }, 5000); + } catch (error) { + console.error("Failed to start recording:", error); + alert("Failed to access microphone. Please check permissions."); + } + }; + + // 处理音频上传 + const handleAudioUpload = (roleIndex: number) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "audio/*"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + // 这里可以添加实际的上传逻辑 + console.log("Audio file uploaded:", file.name); + // 模拟上传成功,设置音频URL + const audioUrl = URL.createObjectURL(file); + setRoleAudios((prev) => ({ ...prev, [roleIndex]: audioUrl })); + + // 保存到模板资源中 + if (selectedTemplate) { + const roleName = selectedTemplate.storyRole[roleIndex]; + + // 查找是否已存在该角色的资源记录 + const existingResourceIndex = + selectedTemplate.userResources.findIndex( + (resource) => resource.role_name === roleName + ); + + let updatedTemplate; + if (existingResourceIndex >= 0) { + // 如果已存在,更新现有记录 + const updatedResources = [...selectedTemplate.userResources]; + updatedResources[existingResourceIndex] = { + ...updatedResources[existingResourceIndex], + voice_url: audioUrl, + }; + updatedTemplate = { + ...selectedTemplate, + userResources: updatedResources, + }; + } else { + // 如果不存在,创建新记录 + updatedTemplate = { + ...selectedTemplate, + userResources: [ + ...selectedTemplate.userResources, + { + role_name: roleName, + photo_url: "", + voice_url: audioUrl, + }, + ], + }; + } + + setSelectedTemplate(updatedTemplate); + } + } + }; + input.click(); + }; + + // 删除角色音频 + const handleDeleteRoleAudio = (roleIndex: number) => { + setRoleAudios((prev) => { + const newState = { ...prev }; + delete newState[roleIndex]; + return newState; + }); + + // 同时从模板资源中删除音频 + if (selectedTemplate) { + const roleName = selectedTemplate.storyRole[roleIndex]; + const existingResourceIndex = selectedTemplate.userResources.findIndex( + (resource) => resource.role_name === roleName + ); + + if (existingResourceIndex >= 0) { + const updatedResources = [...selectedTemplate.userResources]; + updatedResources[existingResourceIndex] = { + ...updatedResources[existingResourceIndex], + voice_url: "", + }; + + const updatedTemplate = { + ...selectedTemplate, + userResources: updatedResources, + }; + setSelectedTemplate(updatedTemplate); + } + } + }; + + return ( + <> + +
+ {/* 使用 Ant Design Tooltip */} + +
+ ? +
+
+ {/* 模板列表 - 横向滚动 */} +
+
+ {loading ? ( + // Loading state +
+
+
+ ) : ( + // Template icon list + templates.map((template) => ( +
handleTemplateSelect(template)} + > + {/* 模板卡片容器 */} +
+ {/* 图片区域 - 占满整个容器 */} +
+ {template.name} +
+
+ + {/* 鼠标悬停信息覆盖层 */} +
+ {/* 故事名称 */} +

+ {template.name} +

+ {/* 角色数量 */} +
+ {template.storyRole.length} Characters +
+
+
+
+ )) + )} +
+
+ + {/* 模板详情弹窗 */} + { + // 清空所有选中的内容数据 + setRoleImages({}); + setRoleAudios({}); + setSelectedTemplate(null); + setSelectedRoleIndex(0); + setIsModalOpen(false); + }} + footer={null} + width="50%" + style={{ maxWidth: "600px" }} + className="template-modal" + closeIcon={ +
+ × +
+ } + > + {selectedTemplate && ( +
+ {/* 弹窗头部 */} +
+ {/* 左侧图片 - 减小尺寸 */} +
+
+ {selectedTemplate.name} +
+
+
+ + {/* 右侧信息 - 垂直从上排列,支持滚动 */} +
+

+ {selectedTemplate.name} +

+
+

+ {selectedTemplate.generateText} +

+
+
+
+ + {/* 角色自定义部分 */} +
+

+ Character Customization +

+ + {/* 角色Tab切换 */} +
+
+ {selectedTemplate.storyRole.map((role: string, index: number) => ( + + ))} +
+ + {/* 当前选中角色的自定义内容 */} + {selectedRoleIndex !== null && ( +
+ {/* 图片上传部分 */} +
+ {/* 图片上传提示 */} +
+
+
+ +
+
+

+ Character Photo +

+

+ Upload a portrait photo to replace this character's appearance in the movie. +

+
+
+
+ + {/* 图片上传区域 - 放大尺寸 */} +
+ {roleImages[selectedRoleIndex] ? ( +
+ Character + +
+ ) : ( + false} + onChange={(info) => { + if (info.file.status === "done") { + handleRoleImageUpload( + selectedRoleIndex, + info.file.originFileObj + ); + } + }} + > +
+ + Upload Photo +
+
+ )} +
+
+ + {/* 音频部分 */} +
+ {/* 音频录制提示 */} +
+
+
+ +
+
+

+ Character Voice +

+
+

+ Record your voice to replace this character's voice in the movie. + Aim for 15 seconds of clear, natural speech. +

+
+

+ "The sun sets slowly behind the mountain, casting a warm glow over the calm valley." +

+
+
+
+
+
+ + {/* 音频操作区域 */} +
+ {/* 音频播放器或占位符 */} + {roleAudios[selectedRoleIndex] ? ( +
+ + {/* 删除按钮集成到播放器中 - 更小且位于右上角 */} + +
+ ) : ( +
+ + No Audio Recorded + +
+ )} + + {/* 音频操作按钮 - 只在没有音频时显示 */} + {!roleAudios[selectedRoleIndex] && ( +
+ {/* 录制音频按钮 */} + + + + + {/* 上传音频按钮 */} + + + +
+ )} +
+
+
+ )} +
+
+ + {/* 弹窗底部操作 - 只保留 Action 按钮 */} +
+
+ {loading ? ( + <> + + Actioning... + + ) : ( + <> + + Action + + )} +
+
+
+ )} + +
+ + ); +}; +/**照片故事模式 */ +const RenderPhotoStoryMode = ({ + photoStory, + setPhotoStory, + hasGenerated, + setHasGenerated, +}: { + photoStory: Partial; + setPhotoStory: React.Dispatch< + React.SetStateAction> + >; + hasGenerated: boolean; + setHasGenerated: React.Dispatch>; +}) => { + const [isGenerating, setIsGenerating] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + // 故事分类选项 + const storyTypes = [ + { key: "auto", label: "Auto" }, + { key: "adventure", label: "Adventure" }, + { key: "romance", label: "Romance" }, + { key: "mystery", label: "Mystery" }, + { key: "fantasy", label: "Fantasy" }, + { key: "comedy", label: "Comedy" }, + ]; + + // 处理图片上传 + const handleImageUpload = async () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + try { + setIsUploading(true); + // 模拟图片上传,实际项目中替换为真实API + const imageUrl = URL.createObjectURL(file); + setPhotoStory((prev) => ({ ...prev, imageUrl })); + console.log("Image uploaded successfully:", file.name); + } catch (error) { + console.error("Upload error:", error); + alert("Upload failed, please try again"); + } finally { + setIsUploading(false); + } + } + }; + input.click(); + }; + + // 处理AI生成故事 + const handleGenerateStory = async () => { + if (!photoStory.imageUrl || !photoStory.imageStory) { + alert("Please upload an image and enter story inspiration first"); + return; + } + + setIsGenerating(true); + try { + // 模拟AI生成故事,实际项目中替换为真实API + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const generatedStory = `Based on your image and inspiration "${photoStory.imageStory}", I've created a captivating story that captures the essence of your vision. The narrative weaves together elements of mystery and wonder, creating an engaging tale that will captivate your audience.`; + + setPhotoStory((prev) => ({ ...prev, imageStory: generatedStory })); + setHasGenerated(true); + } catch (error) { + console.error("Story generation failed:", error); + alert("Story generation failed, please try again"); + } finally { + setIsGenerating(false); + } + }; + + // 处理重置 + const handleReset = () => { + setPhotoStory({ + imageUrl: "", + imageStory: "", + storyType: "auto", + }); + setHasGenerated(false); + }; + + // 处理故事文本变化 + const handleStoryChange = (e: React.ChangeEvent) => { + setPhotoStory((prev) => ({ ...prev, imageStory: e.target.value })); + }; + + // 处理分类选择 + const handleTypeChange = (value: string) => { + setPhotoStory((prev) => ({ ...prev, storyType: value })); + }; + + return ( +
+ + {/* 图片上传区域 */} +
+ {/* 图片上传按钮 */} +
+
+ {isUploading ? ( + + ) : photoStory.imageUrl ? ( + Story Image + ) : ( + + )} +
+
+ + {isUploading + ? "Uploading..." + : photoStory.imageUrl + ? "Image" + : "Add Image"} + +
+
+ + {/* 文本输入和分类选择区域 */} +
+ {/* 文本输入框 */} +