diff --git a/folder-alias.json b/folder-alias.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/folder-alias.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/private-folder-alias.json b/private-folder-alias.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/private-folder-alias.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/video-gen-app/folder-alias.json b/video-gen-app/folder-alias.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/video-gen-app/folder-alias.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/video-gen-app/package-lock.json b/video-gen-app/package-lock.json index 488e1e75..48e91e00 100644 --- a/video-gen-app/package-lock.json +++ b/video-gen-app/package-lock.json @@ -12,6 +12,7 @@ "antd": "^6.3.7", "dayjs": "^1.11.20", "plyr-react": "^6.0.0", + "qrcode.react": "^4.2.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", @@ -3997,6 +3998,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/video-gen-app/package.json b/video-gen-app/package.json index 0b264020..cc5a4029 100644 --- a/video-gen-app/package.json +++ b/video-gen-app/package.json @@ -14,6 +14,7 @@ "antd": "^6.3.7", "dayjs": "^1.11.20", "plyr-react": "^6.0.0", + "qrcode.react": "^4.2.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", diff --git a/video-gen-app/private-folder-alias.json b/video-gen-app/private-folder-alias.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/video-gen-app/private-folder-alias.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/video-gen-app/src/App.tsx b/video-gen-app/src/App.tsx index d5b10537..57372339 100644 --- a/video-gen-app/src/App.tsx +++ b/video-gen-app/src/App.tsx @@ -8,10 +8,24 @@ import ProjectsPage from './pages/ProjectsPage'; import GeneratePage from './pages/GeneratePage'; import RecordsPage from './pages/RecordsPage'; import CreditsPage from './pages/CreditsPage'; +import GenerateConver from './pages/GenerateConver'; +import InitialReplication from './pages/InitialReplication'; +import RemoveLens from './pages/RemoveLens'; + + + + import { useAuthStore } from './store/useAuthStore'; const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { - const { user, loading } = useAuthStore(); + const { user, loading, checkAuth } = useAuthStore(); + + useEffect(() => { + const token = localStorage.getItem('auth_token'); + if (!token && !loading && !user) { + window.location.href = '/login'; + } + }, [user, loading]); if (loading) { return ( @@ -20,11 +34,10 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { ); } - if (!user) { - return ; + window.location.href = '/login'; + return null; } - return <>{children}; }; @@ -75,6 +88,12 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> + + + } /> diff --git a/video-gen-app/src/api/client.ts b/video-gen-app/src/api/client.ts index c23a4d7a..9deb7932 100644 --- a/video-gen-app/src/api/client.ts +++ b/video-gen-app/src/api/client.ts @@ -106,10 +106,11 @@ export async function apiRequest(path: string, options: RequestOptions = {}): } // Handle error responses - if (!res.ok) { +if (!res.ok) { const msg = parsed?.detail || `请求失败 (${res.status})`; if (res.status === 401) { clearToken(); + window.location.href = '/login'; } throw new Error(msg); } diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 90fee567..3c833cf1 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -77,11 +77,15 @@ export async function optimizePrompt( ): Promise { if (USE_MOCK) return mock.mockOptimizePrompt(projectId, params as any); return api.post('/generation-records/optimize', { - project_id: projectId, + project_id: projectId,//项目id + gen_type:params.genType,//生成类型 prompt: params.prompt, duration: params.duration, references: params.references || null, idempotency_key: params.idempotencyKey || null, + image_size: params.image_size || null, + image_proportion: params.image_proportion || null, + image_px: params.image_px || null, }); } @@ -160,8 +164,13 @@ export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: strin export async function getVideoEngines(): Promise<{ items: { id: string; name: string; provider: string; supportedRatios: string[]; supportedResolutions: string[]; supportedDurations: number[] }[] }> { if (USE_MOCK) return { items: [{ id: 'mock', name: 'Seedance', provider: 'seedance', supportedRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], supportedResolutions: ['480p', '720p', '1080p'], supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] }] }; - return api.get('/video-engines', false); + return api.get('/video-engines'); } +// 参数选择(图片)Parameters +export async function getParameters(): Promise { + return api.get('/image-engines'); +} + // ── SMS ─────────────────────────────────────────────────── @@ -266,3 +275,11 @@ export async function getMenuConfigs(): Promise { export async function getRechargePackages(): Promise { return api.get('/recharge-packages'); } + + +export async function getCreditRatios(): Promise { + return api.get('/credits/ratios'); +} + + + diff --git a/video-gen-app/src/api/mock.ts b/video-gen-app/src/api/mock.ts index af722496..88b8669d 100644 --- a/video-gen-app/src/api/mock.ts +++ b/video-gen-app/src/api/mock.ts @@ -3,6 +3,7 @@ import type { CreditRecord, Project, GenerationRecord, + GenerateParams, OptimizeParams, OptimizeResult, LoginParams, @@ -55,10 +56,14 @@ let MOCK_RECORDS: GenerationRecord[] = [ resolution: '1080p', status: 'completed', videoUrl: 'https://example.com/video1.mp4', - textCreditsCost: 10, - textTokensUsed: 850, creditsCost: 120, - videoTokensUsed: 0, + textCreditsCost: 0, + textTokensUsed: 0, + videoTokensUsed: 120, + imageSize: '1080p', + imageProportion: '9:16', + imagePx: '1080x1920', + imageUrl: '', createdAt: '2026-04-29 14:22:00', generatedAt: '2026-04-29 14:25:00', }, @@ -73,10 +78,14 @@ let MOCK_RECORDS: GenerationRecord[] = [ resolution: '1080p', status: 'completed', videoUrl: 'https://example.com/video2.mp4', - textCreditsCost: 8, - textTokensUsed: 720, creditsCost: 80, - videoTokensUsed: 0, + textCreditsCost: 0, + textTokensUsed: 0, + videoTokensUsed: 80, + imageSize: '1080p', + imageProportion: '16:9', + imagePx: '1920x1080', + imageUrl: '', createdAt: '2026-04-30 09:15:00', generatedAt: '2026-04-30 09:18:00', }, @@ -90,10 +99,14 @@ let MOCK_RECORDS: GenerationRecord[] = [ aspectRatio: '16:9', resolution: '4K', status: 'prompt_optimized', - textCreditsCost: 12, - textTokensUsed: 960, creditsCost: 120, - videoTokensUsed: 0, + textCreditsCost: 0, + textTokensUsed: 0, + videoTokensUsed: 120, + imageSize: '4K', + imageProportion: '16:9', + imagePx: '3840x2160', + imageUrl: '', createdAt: '2026-05-01 16:40:00', }, { @@ -106,10 +119,14 @@ let MOCK_RECORDS: GenerationRecord[] = [ aspectRatio: '16:9', resolution: '4K', status: 'prompt_optimized', - textCreditsCost: 10, - textTokensUsed: 880, creditsCost: 100, - videoTokensUsed: 0, + textCreditsCost: 0, + textTokensUsed: 0, + videoTokensUsed: 100, + imageSize: '4K', + imageProportion: '16:9', + imagePx: '3840x2160', + imageUrl: '', createdAt: '2026-05-03 08:30:00', }, ]; @@ -169,15 +186,14 @@ export async function mockDeleteProject(id: string): Promise { export async function mockOptimizePrompt( projectId: string, params: OptimizeParams -): Promise { +): Promise { await delay(1500); const project = MOCK_PROJECTS.find((p) => p.id === projectId); - const textCredits = Math.max(1, Math.ceil(params.prompt.length * 0.3)); - const textTokens = Math.round(params.prompt.length * 1.2); + const cost = Math.round(80 + params.prompt.length * 0.5 + params.duration * 2); if (currentUser) { - currentUser.credits -= textCredits; + currentUser.credits -= cost; } const optimizedPromptMap: Record = { @@ -195,7 +211,7 @@ export async function mockOptimizePrompt( if (matched) { optimizedPrompt = optimizedPromptMap[matched]; } else { - optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。`; + optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。${params.aspectRatio}比例构图,${params.resolution}高清画质呈现。`; } const record: GenerationRecord = { @@ -205,17 +221,23 @@ export async function mockOptimizePrompt( originalPrompt: params.prompt, optimizedPrompt, duration: params.duration, + aspectRatio: params.aspectRatio as any, + resolution: params.resolution as any, status: 'prompt_optimized', - textCreditsCost: textCredits, - textTokensUsed: textTokens, - creditsCost: 0, + creditsCost: cost, + textCreditsCost: cost, + textTokensUsed: 0, videoTokensUsed: 0, + imageSize: params.resolution || '1080p', + imageProportion: params.aspectRatio || '16:9', + imagePx: '1920x1080', + imageUrl: '', createdAt: new Date().toLocaleString('zh-CN'), }; MOCK_RECORDS.unshift(record); - return { optimizedPrompt, textCreditsCost: textCredits, textTokensUsed: textTokens, record }; + return { optimizedPrompt, textCreditsCost: cost, textTokensUsed: 0, record }; } export async function mockGenerateVideo(recordId: string): Promise { @@ -241,10 +263,10 @@ export async function mockGetGenerationRecords(projectId?: string): Promise { const [unreadCount, setUnreadCount] = useState(0); const [siteName, setSiteName] = useState('VideoGen.AI'); const [siteLogo, setSiteLogo] = useState(''); + const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false); + const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null); useEffect(() => { getSiteInfo().then(info => { @@ -240,7 +244,7 @@ const AppLayout: React.FC = () => { {/* Menu */} -
+
{!collapsed && (
导航 @@ -500,7 +504,27 @@ const AppLayout: React.FC = () => {
+ {/* QR Code Payment Modal */} + { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }} + footer={null} + width={400} + closable={false} + styles={{ + body: { padding: 0, borderRadius: 16, overflow: 'hidden' }, + }} + > +
+ {/* Header */} +
+
+ +
+ 微信支付 + 请使用微信扫描二维码完成支付 +
+ + {/* QR Code */} +
+
+ {currentPaymentInfo && ( + + )} +
+
+
+ ¥{currentPaymentInfo?.price || 0} +
+
+ 购买 {currentPaymentInfo?.credits || 0} 积分 +
+
+
+ + {/* Tips */} +
+
+
💡
+
+
支付提示
+
    +
  • 请在支付后等待页面自动跳转
  • +
  • 如支付成功但未到账,请联系客服
  • +
+
+
+
+ + {/* Footer Buttons */} +
+ + +
+
+
+ {/* Message Center Modal */} = ({ value, const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); - setDisplay(Math.round(start + diff * eased)); + setDisplay(start + diff * eased); if (progress < 1) ref.current = requestAnimationFrame(animate); }; ref.current = requestAnimationFrame(animate); diff --git a/video-gen-app/src/pages/GenerateConver.tsx b/video-gen-app/src/pages/GenerateConver.tsx new file mode 100644 index 00000000..c7506169 --- /dev/null +++ b/video-gen-app/src/pages/GenerateConver.tsx @@ -0,0 +1,914 @@ +/** + * AI对话页面组件 + * + * 功能说明: + * - 提供AI聊天界面,支持创建对话、发送消息、上传图片/视频、删除对话等功能 + * - 支持媒体类型选择(图片/视频)和生成数量选择(1-10) + * - 消息列表自动滚动到底部 + * - 响应式侧边栏(可收起/展开) + * + * 技术栈:React + TypeScript + Ant Design + * + * 页面结构: + * - 左侧:对话列表侧边栏 + * - 右侧:消息展示区 + 输入区域 + * + * @component AIChatPage + * @returns {React.ReactElement} AI聊天页面组件 + */ +import React, { useState, useRef, useEffect } from 'react'; + +// Ant Design组件导入 +import { + Layout, // 布局组件 + Button, // 按钮组件 + Input, // 输入框组件 + Select, // 选择器组件 + Upload, // 文件上传组件 + message, // 消息提示组件 + Space, // 间距组件 + Typography, // 排版组件 + Tooltip, // 提示组件 + Popconfirm, // 确认弹窗组件 +} from 'antd'; + +// Ant Design图标导入 +import { + PlusOutlined, // 加号图标(新建对话) + MenuUnfoldOutlined, // 展开菜单图标 + MenuFoldOutlined, // 收起菜单图标 + SendOutlined, // 发送图标 + DeleteOutlined, // 删除图标 + RobotOutlined, // 机器人图标(AI头像) + LoadingOutlined, // 加载图标 + PictureOutlined, // 图片图标 + VideoCameraOutlined, // 视频图标 +} from '@ant-design/icons'; + +// 解构Layout组件 +const { Header, Sider, Content } = Layout; +// 解构Input组件 +const { TextArea } = Input; +// 解构Select组件 +const { Option } = Select; +// 解构Typography组件 +const { Text } = Typography; + +/** + * 消息类型定义 + * @interface Message + * @property {string} id - 消息唯一标识 + * @property {'user' | 'bot'} type - 消息发送者类型 + * @property {string} content - 消息文本内容 + * @property {string} timestamp - 消息发送时间 + * @property {string[]} [images] - 消息附带的图片URL列表(可选) + */ +interface Message { + id: string; + type: 'user' | 'bot'; + content: string; + timestamp: string; + images?: string[]; +} + +/** + * 对话类型定义 + * @interface Conversation + * @property {string} id - 对话唯一标识 + * @property {string} title - 对话标题 + * @property {string} lastMessage - 最后一条消息预览 + * @property {string} timestamp - 最后消息时间 + * @property {Message[]} messages - 消息列表 + */ +interface Conversation { + id: string; + title: string; + lastMessage: string; + timestamp: string; + messages: Message[]; +} + +/** + * 模拟文件上传函数 + * @param {File} file - 要上传的文件对象 + * @returns {Promise<{ url: string }>} 返回包含文件URL的Promise + * @description 模拟真实的文件上传过程,延迟500ms后返回文件的Object URL + */ +const mockUpload = (file: File): Promise<{ url: string }> => { + return new Promise((resolve) => { + setTimeout(() => { + // 使用URL.createObjectURL创建本地文件预览URL + const url = URL.createObjectURL(file); + resolve({ url }); + }, 500); + }); +}; + +/** + * 模拟AI回复函数 + * @param {string} content - 用户输入的内容 + * @returns {Promise} 返回AI回复内容的Promise + * @description 模拟AI响应过程,延迟1500ms后随机返回一条预设回复 + */ +const mockAIResponse = (content: string): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + const responses = [ + `好的,我来帮您创作关于"${content}"的内容...`, + `您的想法很有趣!关于"${content}",我有以下建议:`, + `收到!正在为您生成"${content}"相关的内容...`, + `太棒了!"${content}"是一个很棒的主题,让我来帮您实现。`, + ]; + // 随机选择一条回复 + resolve(responses[Math.floor(Math.random() * responses.length)]); + }, 1500); + }); +}; + +/** + * AI聊天页面主组件 + * @function AIChatPage + * @returns {React.ReactElement} AI聊天页面 + */ +const AIChatPage: React.FC = () => { + // ==================== 状态定义 ==================== + + /** + * 侧边栏收起/展开状态 + * @state {boolean} collapsed - true表示收起,false表示展开 + */ + const [collapsed, setCollapsed] = useState(false); + + /** + * 当前选中的对话ID + * @state {string | null} currentConversationId - 当前活跃对话的ID,null表示未选中任何对话 + */ + const [currentConversationId, setCurrentConversationId] = useState(null); + + /** + * 对话列表 + * @state {Conversation[]} conversations - 存储所有对话数据,包含初始模拟数据 + */ + const [conversations, setConversations] = useState([ + { + id: '1', + title: '女生产品文案', + lastMessage: '女生拿着这个产品', + timestamp: '2026-05-26 09:41:07', + messages: [ + { + id: 'm1', + type: 'user', + content: '女生拿着这个产品', + timestamp: '2026-05-26 09:41:07', + images: ['https://neeko-copilot.bytedance.net/api/text_to_image?prompt=woman%20holding%20luxury%20cream%20jar%20elegant%20bathroom&image_size=portrait_4_3'], + }, + { + id: 'm2', + type: 'bot', + content: '好的,我来为您生成关于这个产品的创意内容...', + timestamp: '2026-05-26 09:41:10', + }, + ], + }, + { + id: '2', + title: '旅行视频脚本', + lastMessage: '帮我写一个旅行vlog脚本', + timestamp: '2026-05-25 14:30:22', + messages: [ + { + id: 'm3', + type: 'user', + content: '帮我写一个旅行vlog脚本', + timestamp: '2026-05-25 14:30:22', + }, + { + id: 'm4', + type: 'bot', + content: '当然!我来帮您构思一个精彩的旅行vlog脚本...', + timestamp: '2026-05-25 14:30:25', + }, + ], + }, + ]); + + /** + * 输入框内容 + * @state {string} inputValue - 用户在文本输入框中输入的内容 + */ + const [inputValue, setInputValue] = useState(''); + + /** + * 媒体类型选择(图片/视频) + * @state {string} mediaType - 'image'表示图片,'video'表示视频 + */ + const [mediaType, setMediaType] = useState('image'); + + /** + * 生成数量选择(1-10) + * @state {string} countType - 生成内容的数量,值为'1'到'10' + */ + const [countType, setCountType] = useState('1'); + + /** + * 文件上传状态 + * @state {boolean} uploading - true表示正在上传文件 + */ + const [uploading, setUploading] = useState(false); + + /** + * AI响应加载状态 + * @state {boolean} loading - true表示AI正在生成回复 + */ + const [loading, setLoading] = useState(false); + + /** + * 当前上传的图片URL列表 + * @state {string[]} currentImages - 存储当前会话中待发送的图片URL + */ + const [currentImages, setCurrentImages] = useState([]); + + // ==================== 引用定义 ==================== + + /** + * 消息列表底部引用,用于自动滚动 + * @ref {HTMLDivElement | null} messagesEndRef - 指向消息列表最后一个元素 + */ + const messagesEndRef = useRef(null); + + // ==================== 派生数据 ==================== + + /** + * 获取当前选中的对话对象 + * @const {Conversation | undefined} currentConversation - 当前活跃对话数据 + */ + const currentConversation = conversations.find((c) => c.id === currentConversationId); + + // ==================== 副作用 ==================== + + /** + * 自动滚动到底部 + * @effect 当消息列表更新时,自动滚动到最新消息位置 + * @dependency {currentConversation?.messages} - 监听消息列表变化 + */ + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [currentConversation?.messages]); + + // ==================== 事件处理函数 ==================== + + /** + * 创建新对话 + * @function handleNewChat + * @description 创建一个新的空对话,并切换到该对话 + */ + const handleNewChat = () => { + // 生成唯一ID(使用时间戳) + const newId = Date.now().toString(); + const newConversation: Conversation = { + id: newId, + title: '新对话', + lastMessage: '', + timestamp: new Date().toLocaleString('zh-CN'), + messages: [], + }; + // 将新对话插入到列表顶部 + setConversations((prev) => [newConversation, ...prev]); + // 切换到新对话 + setCurrentConversationId(newId); + // 清空输入框和已上传图片 + setInputValue(''); + setCurrentImages([]); + // 显示提示消息 + message.info('已开启新对话'); + }; + + /** + * 删除对话 + * @function handleDeleteChat + * @param {string} conversationId - 要删除的对话ID + * @description 删除指定对话,并自动切换到其他对话 + */ + const handleDeleteChat = (conversationId: string) => { + // 过滤掉要删除的对话 + setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + // 如果删除的是当前选中的对话,切换到其他对话 + if (currentConversationId === conversationId) { + setCurrentConversationId( + conversations[0]?.id === conversationId + ? conversations[1]?.id || null + : conversations[0]?.id || null + ); + } + // 显示成功提示 + message.success('对话已删除'); + }; + + /** + * 选择对话 + * @function handleSelectChat + * @param {string} conversationId - 要选择的对话ID + * @description 切换到指定对话,清空已上传图片 + */ + const handleSelectChat = (conversationId: string) => { + setCurrentConversationId(conversationId); + setCurrentImages([]); + }; + + /** + * 发送消息 + * @function handleSend + * @async + * @description 发送用户消息,等待AI回复,并更新对话状态 + */ + const handleSend = async () => { + // 验证:必须有内容或图片 + if (!inputValue.trim() && currentImages.length === 0) { + message.warning('请输入内容或上传图片'); + return; + } + + // 创建用户消息对象 + const newMessage: Message = { + id: `m${Date.now()}`, + type: 'user', + content: inputValue.trim(), + timestamp: new Date().toLocaleString('zh-CN'), + images: currentImages.length > 0 ? [...currentImages] : undefined, + }; + + // 更新对话列表,添加用户消息 + setConversations((prev) => + prev.map((c) => + c.id === currentConversationId + ? { + ...c, + messages: [...c.messages, newMessage], + lastMessage: inputValue.trim() || '[图片]', + timestamp: new Date().toLocaleString('zh-CN'), + // 如果是新对话,使用第一条消息作为标题 + title: c.title === '新对话' && inputValue.trim() + ? inputValue.trim().substring(0, 20) + : c.title, + } + : c + ) + ); + + // 清空输入框和已上传图片 + setInputValue(''); + setCurrentImages([]); + // 设置加载状态 + setLoading(true); + + try { + // 调用模拟AI回复 + const response = await mockAIResponse(inputValue.trim() || '图片请求'); + // 创建AI回复消息对象 + const botMessage: Message = { + id: `m${Date.now() + 1}`, + type: 'bot', + content: response, + timestamp: new Date().toLocaleString('zh-CN'), + }; + + // 更新对话列表,添加AI回复 + setConversations((prev) => + prev.map((c) => + c.id === currentConversationId + ? { + ...c, + messages: [...c.messages, botMessage], + lastMessage: response.substring(0, 30) + (response.length > 30 ? '...' : ''), + timestamp: new Date().toLocaleString('zh-CN'), + } + : c + ) + ); + } catch (error) { + // 处理错误 + message.error('发送失败,请重试'); + } finally { + // 无论成功与否,取消加载状态 + setLoading(false); + } + }; + + /** + * 文件上传处理 + * @function handleUpload + * @async + * @param {File} file - 要上传的文件 + * @returns {false} - 返回false阻止自动上传,由自定义逻辑处理 + * @description 验证文件类型和大小,上传文件并添加到当前图片列表 + */ + const handleUpload = async (file: File) => { + // 验证文件类型 + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + + if (!isImage && !isVideo) { + message.error('仅支持图片或视频文件'); + return false; + } + + // 验证文件大小 + const maxMB = isVideo ? 100 : 10; + if (file.size / 1024 / 1024 > maxMB) { + message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`); + return false; + } + + // 验证图片数量(最多4张) + const imageCount = currentImages.filter((_, i) => i < 4).length; + if (imageCount >= 4) { + message.error('最多上传4张图片'); + return false; + } + + // 设置上传状态 + setUploading(true); + + try { + // 调用模拟上传 + const res = await mockUpload(file); + // 添加到图片列表 + setCurrentImages((prev) => [...prev, res.url]); + message.success(`${isImage ? '图片' : '视频'}上传成功`); + } catch (error) { + message.error('上传失败'); + } finally { + setUploading(false); + } + + // 返回false阻止Ant Design的自动上传行为 + return false; + }; + + /** + * 移除已上传的图片 + * @function handleRemoveImage + * @param {number} index - 要移除的图片索引 + * @description 从当前图片列表中移除指定索引的图片 + */ + const handleRemoveImage = (index: number) => { + setCurrentImages((prev) => prev.filter((_, i) => i !== index)); + }; + + /** + * 按回车键发送 + * @function handleKeyPress + * @param {React.KeyboardEvent} e - 键盘事件对象 + * @description 监听回车键,非Shift+Enter时发送消息 + */ + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + // ==================== 渲染 ==================== + + return ( + + {/* 左侧边栏 - 对话列表 */} + +
+ {/* 收起/展开按钮 */} + + )} + + {/* 对话列表 - 展开状态显示 */} + {!collapsed && conversations.length > 0 && ( +
+ {conversations.map((conversation) => ( +
handleSelectChat(conversation.id)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '10px 12px', + marginBottom: 4, + borderRadius: 8, + cursor: 'pointer', + background: currentConversationId === conversation.id ? '#f0f5ff' : 'transparent', + border: currentConversationId === conversation.id ? '1px solid #e0e8ff' : 'none', + transition: 'all 0.2s', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : '#fafafa'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : 'transparent'; + }} + > + {/* 对话信息区域 */} +
+

+ {conversation.title} +

+

+ {conversation.lastMessage || '暂无消息'} +

+
+ {/* 删除按钮 */} + handleDeleteChat(conversation.id)} + okText="确定" + cancelText="取消" + > +
+ ))} +
+ )} +
+
+ + {/* 主内容区 */} + + {/* 头部 - 显示对话标题和模型信息 */} +
+
+ + {currentConversation?.title || '开启创作'} + +
+
+ 模型: Gemini 3 Pro + 比例: 9:16 + 消耗 15 积分 +
+
+ + {/* 消息区域 */} + + {/* 空状态 - 未选择对话时显示 */} + {!currentConversation && ( +
+
+ +
+

+ 你好,想创作什么? +

+

+ 输入想法、剧本或上传参考,开始你的创作之旅 +

+
+ )} + + {/* 消息列表 - 有对话时显示 */} + {currentConversation && ( +
+ {/* 遍历消息列表 */} + {currentConversation.messages.map((message) => ( +
+
+ {/* 头像 */} +
+ +
+ + {/* 消息内容 */} +
+ {/* 消息气泡 */} +
+ {/* 图片内容 */} + {message.images && message.images.length > 0 && ( +
+ {message.images.map((img, idx) => ( + {`图片${idx + ))} +
+ )} + {/* 文本内容 */} +

+ {message.content} +

+
+ {/* 时间戳 */} +

+ {message.timestamp} +

+
+
+
+ ))} + + {/* 加载中动画 - AI回复时显示 */} + {loading && ( +
+
+
+ +
+
+
+ + + +
+
+
+
+ )} + + {/* 消息列表底部标记 - 用于自动滚动 */} +
+
+ )} + + {/* 输入区域 - 有对话时显示 */} + {currentConversation && ( +
+ {/* 已上传图片预览 */} + {currentImages.length > 0 && ( +
+ {currentImages.map((img, idx) => ( +
+ {`已上传${idx + {/* 删除已上传图片按钮 */} + +
+ ))} +
+ )} + + {/* 输入框区域 */} +
+ {/* 上传按钮 */} + + +
{ + e.currentTarget.style.borderColor = '#6366f1'; + e.currentTarget.style.background = 'rgba(99,102,241,0.04)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = '#d9d9d9'; + e.currentTarget.style.background = 'transparent'; + }} + > + {uploading ? ( + + ) : ( + + )} +
+
+
+ + {/* 文本输入框 */} +