diff --git a/video-gen-app/src/api/client.ts b/video-gen-app/src/api/client.ts index 9deb7932..7136298e 100644 --- a/video-gen-app/src/api/client.ts +++ b/video-gen-app/src/api/client.ts @@ -13,6 +13,7 @@ interface RequestOptions { body?: unknown; auth?: boolean; encryptBody?: boolean; + signal?: AbortSignal; } /** Convert snake_case string to camelCase */ @@ -53,7 +54,7 @@ async function tryDecrypt(data: string): Promise { } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { - const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options; + const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal } = options; const headers: Record = { 'Content-Type': 'application/json', @@ -79,6 +80,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): method, headers, body: bodyStr, + signal, }); if (res.status === 204) return undefined as T; @@ -120,7 +122,10 @@ if (!res.ok) { // Convenience methods export const api = { - get: (path: string, auth = true) => apiRequest(path, { auth }), + get: (path: string, options: boolean | { auth?: boolean; signal?: AbortSignal } = true) => { + const opts = typeof options === 'boolean' ? { auth: options } : options; + return apiRequest(path, opts); + }, post: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'POST', body, auth }), put: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'PUT', body, auth }), delete: (path: string, auth = true) => apiRequest(path, { method: 'DELETE', auth }), diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index eaab10db..9695d3ff 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -66,10 +66,47 @@ export async function deleteProject(id: string): Promise { // ── Generation ──────────────────────────────────────────── -export async function getRecords(projectId?: string): Promise { - if (USE_MOCK) return mock.mockGetGenerationRecords(projectId); - const q = projectId ? `?project_id=${projectId}` : ''; - return api.get(`/generation-records${q}`); +export interface GenerationRecordPageListOut { + page: number; + pageSize: number; + total: number; + items: GenerationRecord[]; +} + +export interface GetRecordsPageParams { + projectId?: string; + status?: string; + page?: number; + pageSize?: number; + signal?: AbortSignal; +} + +export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise { + const page = params.page && params.page > 0 ? params.page : 1; + const pageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 10; + + if (USE_MOCK) { + const mockRecords = await mock.mockGetGenerationRecords(params.projectId); + const filtered = params.status + ? mockRecords.filter((record) => record.status === params.status) + : mockRecords; + const start = (page - 1) * pageSize; + + return { + page, + pageSize, + total: filtered.length, + items: filtered.slice(start, start + pageSize), + }; + } + + const query = new URLSearchParams(); + if (params.projectId) query.set('project_id', params.projectId); + if (params.status) query.set('status', params.status); + query.set('page', String(page)); + query.set('page_size', String(pageSize)); + + return api.get(`/generation-records?${query.toString()}`, { signal: params.signal }); } export async function optimizePrompt( @@ -288,19 +325,19 @@ export async function getEngine(): Promise { // ── Generation AI Tasks ──────────────────────────────────── - +// 创建ai生成任务 export async function createGenerationTask(params: any): Promise { return api.post('/generation-ai/tasks', params); } - +// 获取ai生成任务列表 export async function getgen_list(Pagebreak: any): Promise { return api.get('/generation-ai/tasks?page='+Pagebreak.page+'&page_size='+Pagebreak.pageSize); } - +// 获取生成任务历史记录 export async function gethistory(Pagebreak: any): Promise { return api.get('/generation-ai/history'+Pagebreak); } - +// 获取生成任务历史记录子项 export async function gethistoryItems(Pagebreak: any): Promise { return api.get('/generation-ai/history/'+Pagebreak); } diff --git a/video-gen-app/src/api/mock.ts b/video-gen-app/src/api/mock.ts index b778310a..75af7a0f 100644 --- a/video-gen-app/src/api/mock.ts +++ b/video-gen-app/src/api/mock.ts @@ -47,6 +47,7 @@ let MOCK_PROJECTS: Project[] = [ let MOCK_RECORDS: GenerationRecord[] = [ { id: 'r-1', + items: [], projectId: 'p-1', projectName: '618电商大促宣传片', originalPrompt: '一个年轻女性在时尚直播间推荐夏季新款连衣裙,背景明亮温馨', @@ -69,6 +70,7 @@ let MOCK_RECORDS: GenerationRecord[] = [ }, { id: 'r-2', + items: [], projectId: 'p-2', projectName: '在线课程推广视频', originalPrompt: '学生在明亮的教室里用平板电脑学习编程课程', @@ -91,7 +93,9 @@ let MOCK_RECORDS: GenerationRecord[] = [ }, { id: 'r-3', + items: [], projectId: 'p-1', + projectName: '618电商大促宣传片', originalPrompt: '多个快递包裹从仓库货架上飞出,物流车快速配送', optimizedPrompt: '高速摄影风格,镜头从大型智能仓储中心内部开始,自动化机械臂精准抓取印有品牌Logo的快递包裹。包裹沿传送带高速移动,在分拣中心精准落入对应区域。画面切换至无人机和无人配送车在城市街道上进行最后一公里配送。最终画面定格在消费者微笑签收包裹的瞬间。整体采用蓝色科技感色调,快节奏剪辑。', @@ -111,6 +115,7 @@ let MOCK_RECORDS: GenerationRecord[] = [ }, { id: 'r-4', + items: [], projectId: 'p-3', projectName: '新游戏预告片', originalPrompt: '一个奇幻世界里的魔法城堡,龙在天空飞过', @@ -217,6 +222,7 @@ export async function mockOptimizePrompt( const record: GenerationRecord = { id: `r-${Date.now()}`, projectId, + items: [], projectName: project?.name ?? '未知项目', originalPrompt: params.prompt, optimizedPrompt, diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index d83cd41b..115834ff 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -389,15 +389,17 @@ const AppLayout: React.FC = () => { {/* Floating sidebar toggle hover zone */}
setToggleHover(true)} onMouseLeave={() => setToggleHover(false)} style={{ position: 'fixed', left: sidebarW - 16, top: 0, bottom: 0, zIndex: 110, width: 32, cursor: 'default', + transition: 'left 0.25s ease', }} > {toggleHover && ( -
setCollapsed(!collapsed)} style={{ +
setCollapsed(prev => !prev)} style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', width: 24, height: 56, borderRadius: '0 8px 8px 0', diff --git a/video-gen-app/src/components/NotificationPopup.tsx b/video-gen-app/src/components/NotificationPopup.tsx index 70d569f9..742059de 100644 --- a/video-gen-app/src/components/NotificationPopup.tsx +++ b/video-gen-app/src/components/NotificationPopup.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { Tag, Typography, Button } from 'antd'; import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons'; import { getNotifications, markNotificationRead } from '../api'; +import { useAuthStore } from '../store/useAuthStore'; interface Notification { id: string; @@ -22,6 +23,7 @@ const NotificationPopup: React.FC = () => { const [visible, setVisible] = useState(false); const [notifications, setNotifications] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); + const refreshUser = useAuthStore((state) => state.refreshUser); const fetchNotifications = useCallback(async () => { try { @@ -52,6 +54,8 @@ const NotificationPopup: React.FC = () => { setVisible(false); setNotifications([]); setCurrentIndex(0); + // 刷新用户信息(包括积分) + try { await refreshUser(); } catch { /* ignore */ } } }; diff --git a/video-gen-app/src/index.css b/video-gen-app/src/index.css index 1b0d4315..0e58969c 100644 --- a/video-gen-app/src/index.css +++ b/video-gen-app/src/index.css @@ -146,6 +146,7 @@ html, body { @media (max-width: 768px) { .desktop-sidebar { display: none !important; } .desktop-content { margin-left: 0 !important; padding: 16px !important; padding-bottom: 80px !important; overflow-x: hidden !important; } + .desktop-sidebar-toggle-zone { display: none !important; } .mobile-bottom-nav { display: flex !important; } /* Prevent horizontal overflow globally */ @@ -319,11 +320,13 @@ html, body { .mobile-mb-12 { margin-bottom: 12px !important; } } -/* ── Tablet Breakpoint (768px to 1024px) ────────────── */ +/* ── Tablet Breakpoint (769px to 1024px) ────────────── */ @media (min-width: 769px) and (max-width: 1024px) { - /* Desktop sidebar adjustments */ - .desktop-sidebar { width: 180px !important; } - .desktop-content { margin-left: 180px !important; } + /* + 左侧栏宽度必须统一由 AppLayout.tsx 的 sidebarW 控制。 + 不要在这里覆盖 .desktop-sidebar width 或 .desktop-content margin-left, + 否则展开/折叠状态会和真实布局宽度不一致。 + */ /* Card max-width */ .ant-card { max-width: calc(50% - 8px) !important; } diff --git a/video-gen-app/src/pages/GenerateConver.tsx b/video-gen-app/src/pages/GenerateConver.tsx index b76b60fd..9bc0d2b9 100644 --- a/video-gen-app/src/pages/GenerateConver.tsx +++ b/video-gen-app/src/pages/GenerateConver.tsx @@ -1,5 +1,5 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { Layout, @@ -16,7 +16,7 @@ import { } from 'antd'; import { getParameters, createGenerationTask, getgen_list,getEngine,uploadImage, - uploadVideo, } from '../api'; + uploadVideo, getCreditRatios } from '../api'; import { PlusOutlined, @@ -112,11 +112,57 @@ const AIChatPage: React.FC = () => { const [currentMedia, setCurrentMedia] = useState<{ name: string; type: 'image' | 'video'; url: string }[]>([]); const [previewVisible, setPreviewVisible] = useState(false); - const [previewUrl, setPreviewUrl] = useState(''); - const [previewType, setPreviewType] = useState<'image' | 'video'>('image'); + // 从URL中提取exp时间戳(支持相对路径和完整URL) + const extractExpTimestamp = (url: string): number | null => { + if (!url) return null; + + try { + // 尝试作为完整URL解析 + const urlObj = new URL(url); + const expStr = urlObj.searchParams.get('exp'); + if (expStr) { + return parseInt(expStr, 10); + } + return null; + } catch { + // 如果完整URL解析失败,尝试解析相对路径中的查询参数 + try { + // 查找 ? 后面的部分 + const queryStart = url.indexOf('?'); + if (queryStart !== -1) { + const queryString = url.substring(queryStart + 1); + const params = new URLSearchParams(queryString); + const expStr = params.get('exp'); + if (expStr) { + return parseInt(expStr, 10); + } + } + return null; + } catch { + return null; + } + } + }; + + // 检查媒体是否过期 + const isMediaExpired = (url: string): boolean => { + const expTimestamp = extractExpTimestamp(url); + if (!expTimestamp) { + return false; // 没有exp参数,视为不过期 + } + const currentTimestamp = Math.floor(Date.now() / 1000); + return currentTimestamp > expTimestamp; + }; + + // 附件预览弹窗状态(独立弹窗) + const [attachmentPreviewVisible, setAttachmentPreviewVisible] = useState(false); + const [attachmentPreviewUrl, setAttachmentPreviewUrl] = useState(''); + const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video'>('image'); + const [attachmentPreviewName, setAttachmentPreviewName] = useState(''); + // 附件详情悬浮窗状态 const [attachmentPopupVisible, setAttachmentPopupVisible] = useState(false); const [attachmentPopupMessageId, setAttachmentPopupMessageId] = useState(null); @@ -128,6 +174,7 @@ const AIChatPage: React.FC = () => { const [width, setWidth] = useState(2048); const [height, setHeight] = useState(2048); const [showImageSettingsModal, setShowImageSettingsModal] = useState(false); + const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false); const [ratioOptions, setRatioOptions] = useState([]); const [resolutionOptions, setResolutionOptions] = useState([]); const [widthandheight, setWidthandHeight] = useState([]); @@ -150,6 +197,54 @@ const AIChatPage: React.FC = () => { }); const [enginesele, setEnginesele] = useState([]); + const [creditRatios, setCreditRatios] = useState([]); + const [cimage, setCimage] = useState([]); + + // 计算视频积分 + const calcVideoCredits = (duration: number, resolution: string): number => { + for (let i = 0; i < creditRatios.length; i++) { + const ratio = creditRatios[i]; + if (ratio.resolution === resolution) { + const result = Math.round((ratio.baseCredits + ratio.perSecondCredits * duration) * ratio.ratio); + return result; + } + } + return 0; + }; + + // 计算图片积分 + const getImageCredits = (imageSize: string): number => { + + + // 将 imageSize 转换为 cimage 中的 resolution 格式 + const resolution = imageSize + + // 尝试精确匹配 + for (let i = 0; i < cimage.length; i++) { + const item = cimage[i]; + + if (item.resolution === resolution) { + return item.baseCredits; + } + } + + // 如果没有精确匹配,尝试查找第一个可用的积分配置 + // if (cimage.length > 0) { + // console.log('getImageCredits fallback to first item:', cimage[0].baseCredits); + // return cimage[0].baseCredits; + // } + + return 0; + }; + + // 获取预估积分 + const getEstimatedCredits = (): number => { + if (mediaType === 'video') { + return calcVideoCredits(videoDuration, videoResolution); + } else { + return getImageCredits(selectedResolution); + } + }; const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); @@ -167,19 +262,32 @@ const AIChatPage: React.FC = () => { }); const [Totalnumber, setTotalnumber] = useState(0); const [isLoadingMore, setIsLoadingMore] = useState(false); + const isInitialLoadDone = useRef(false); // ==================== 副作用 ==================== - // 初次打开时自动滚动到底部 + // 滚动到底部的辅助函数 + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const doScroll = () => { + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + }; + + requestAnimationFrame(doScroll); + }, []); + + // 初次打开或页面刷新时自动滚动到底部(只在初始加载时执行一次) useEffect(() => { - if (gen_list.length > 0 && isFirstLoadRef.current) { + if (gen_list.length > 0 && !isInitialLoadDone.current) { const timer = setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - isFirstLoadRef.current = false; - }, 100); + scrollToBottom('smooth'); + isInitialLoadDone.current = true; + }, 300); return () => clearTimeout(timer); } - }, [gen_list.length]); - + }, [gen_list.length, scrollToBottom]); + useEffect(() => { const newOptions = mediaType === 'image' ? enginesele.image : enginesele.video; if (newOptions && newOptions.length > 0) { @@ -200,6 +308,13 @@ const AIChatPage: React.FC = () => { }) .catch(() => { }); + getCreditRatios() + .then((data: any) => { + setCreditRatios(data.video || []); + setCimage(data.image || []); + }) + .catch((error) => { + }); getgen_list(Pagebreak).then((data: any) => { let mess_list = data.items let total = data.total @@ -297,6 +412,21 @@ const AIChatPage: React.FC = () => { return () => document.removeEventListener("click", handleClickOutside); }, [showImageSettingsModal]); + useEffect(() => { + if (!showVideoSettingsModal) return; + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if ( + !target.closest(".image-settings-popover") && + !target.closest(".image-settings-trigger") + ) { + setShowVideoSettingsModal(false); + } + }; + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); + }, [showVideoSettingsModal]); + // 监听 blindex 状态变化 useEffect(() => { if ( @@ -413,7 +543,14 @@ const AIChatPage: React.FC = () => { }; + // 设置加载状态 + setLoading(true); + createGenerationTask(newMessage).then(() => { + // 创建任务成功后,清空输入框和已上传媒体 + setInputValue(''); + setCurrentMedia([]); + // 创建任务成功后,重置页数为1,获取最新列表 const newPagebreak = { ...Pagebreak, page: 1 }; setPagebreak(newPagebreak); @@ -427,18 +564,31 @@ const AIChatPage: React.FC = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 100); }).catch((error) => { - console.error('获取列表失败:', error); }); - }).catch((error) => { - console.error('创建任务失败:', error); + }).catch((error: any) => { + + // 失败时不清空输入框和媒体 + + // 尝试从错误对象中提取detail字段 + let errorMessage = "创建任务失败"; + if (error?.response?.data?.detail) { + errorMessage = error.response.data.detail; + } else if (error?.data?.detail) { + errorMessage = error.data.detail; + } else if (error?.detail) { + errorMessage = error.detail; + } else if (error?.message) { + errorMessage = error.message; + } + + message.error({ + content: errorMessage, + duration: 3, + }); + }).finally(() => { setLoading(false); }); - // 清空输入框和已上传媒体 - setInputValue(''); - setCurrentMedia([]); - // 设置加载状态 - setLoading(true); setTimeout(() => { setLoading(false); }, 3000); @@ -456,7 +606,6 @@ const AIChatPage: React.FC = () => { // 保存当前滚动位置(相对于顶部的偏移) const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - console.warn('滚动容器未找到'); return; } @@ -816,7 +965,7 @@ const AIChatPage: React.FC = () => { background: '#fff', borderRadius: '16px 16px 16px 4px', padding: '12px 16px', - maxWidth: '100%', + width: '600px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', }} > @@ -847,10 +996,13 @@ const AIChatPage: React.FC = () => { {message.status === 'generating' && (
-
- - - +
+ 正在生成中 +
+ + + +
@@ -864,46 +1016,51 @@ const AIChatPage: React.FC = () => { )} {/* 已完成 - 显示媒体内容 */} {message.status === 'completed' && ( -
+
{ setPreviewUrl(message.genType === 'image' ? message.imageUrl : message.videoUrl); setPreviewType(message.genType === 'image' ? 'image' : 'video'); setPreviewVisible(true); }} - style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative' }} + style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative' ,height: 200 }} > {message.genType === 'image' ? ( {message.name} { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} /> ) : ( <> -
@@ -1007,12 +1166,13 @@ const AIChatPage: React.FC = () => {
{ - const link = document.createElement('a'); - link.href =`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}` ; - link.download = ref.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + setAttachmentPreviewUrl(ref.url); + setAttachmentPreviewType(ref.url.includes('.mp4') || ref.url.includes('.mov') || ref.url.includes('.avi') || ref.url.includes('.video') ? 'video' : 'image'); + setAttachmentPreviewName(ref.name); + setAttachmentPreviewVisible(true); + // 关闭附件悬浮窗,避免层级覆盖问题 + setAttachmentPopupVisible(false); + setAttachmentPopupMessageId(null); }} style={{ display: 'flex', @@ -1095,7 +1255,16 @@ const AIChatPage: React.FC = () => { )} {/* 输入框区域 */} -
+
{/* 上传按钮 */} { m.type === 'image').length}/4,视频${currentMedia.filter(m => m.type === 'video').length}/1`}>
{ e.currentTarget.style.borderColor = '#6366f1'; - e.currentTarget.style.background = 'rgba(99,102,241,0.04)'; + e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)'; + e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { - e.currentTarget.style.borderColor = '#d9d9d9'; - e.currentTarget.style.background = 'transparent'; + e.currentTarget.style.borderColor = '#cbd5e1'; + e.currentTarget.style.backgroundColor = '#f8fafc'; + e.currentTarget.style.transform = 'scale(1)'; }} > {uploading ? ( - + ) : ( - + )}
@@ -1141,7 +1313,17 @@ const AIChatPage: React.FC = () => { onKeyPress={handleKeyPress} placeholder="上传最多4张参考图,输入提示词描述您想生成的画面..." autoSize={{ minRows: 1, maxRows: 4 }} - style={{ flex: 1, borderRadius: 12, border: 'none', outline: 'none', boxShadow: 'none' }} + style={{ + flex: 1, + borderRadius: 12, + border: 'none', + outline: 'none', + boxShadow: 'none', + fontSize: 14, + lineHeight: 1.5, + color: '#1e293b', + resize: 'none', + }} disabled={loading} /> @@ -1153,7 +1335,14 @@ const AIChatPage: React.FC = () => { onClick={handleSend} disabled={!inputValue.trim() && currentMedia.length === 0} loading={loading} - style={{ flexShrink: 0 }} + style={{ + flexShrink: 0, + width: 48, + height: 48, + borderRadius: 14, + boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)', + transition: 'all 0.2s ease', + }} />
@@ -1161,7 +1350,7 @@ const AIChatPage: React.FC = () => {
{/* 文件类型选择器 */} -
{ background: '#f8f9fc', border: '1px solid #e2e8f0', height: 28, - }}> - 类型 + }}> */} + {/* 类型 */} -
+ {/*
*/} {/* 图片设置按钮 */} {mediaType === 'image' && ( @@ -1479,168 +1668,218 @@ const AIChatPage: React.FC = () => { {/* 视频参数设置 */} {mediaType === 'video' && ( -
- {/* 时长 */} -
- 时长 - -
- - {/* 比例 */} -
- - {expandedEngine === 'ratio' && ( -
e.stopPropagation()} - > - {engineOptions.ratios.map((ratio) => ( - - ))} -
- )} -
- - {/* 分辨率 */} -
- - {expandedEngine === 'resolution' && ( -
e.stopPropagation()} - > - {engineOptions.resolutions.map((resolution) => ( - - ))} +
+ + + + {showVideoSettingsModal && ( +
e.stopPropagation()} + > + {/* 选择比例 */} +
+ + 选择比例 + +
+ {engineOptions.ratios.map((ratio) => ( + + ))} +
- )} -
+ + {/* 选择时长 */} +
+ + 选择时长 + +
+ {engineOptions.durations.map((duration) => ( + + ))} +
+
+ + {/* 选择分辨率 */} +
+ + 选择分辨率 + +
+ {engineOptions.resolutions.map((resolution) => ( + + ))} +
+
+
+ )}
)} @@ -1662,7 +1901,7 @@ const AIChatPage: React.FC = () => { onChange={(val) => { setCountType(val); }} - style={{ width: 200 ,outline:'none',border:'none' }} + style={{ width: 200 ,outline:'none',background: '#f8f9fc',border: '1px solid #e2e8f0', }} size="small" > {(mediaType === 'image' ? enginesele.image : enginesele.video)?.map((item:any) => ( @@ -1670,6 +1909,12 @@ const AIChatPage: React.FC = () => { ))}
+ + {/* 预估积分 */} +
+ 预估积分 + {getEstimatedCredits()} +
{/* 底部信息 */} @@ -1736,20 +1981,114 @@ const AIChatPage: React.FC = () => { × } + bodyStyle={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '400px', + }} > - {previewType === 'image' ? ( - 预览 - ) : ( -
- {/* Generation state */} - {recordStates[currentRecord.id] && - recordStates[currentRecord.id] !== "idle" && ( -
- {recordStates[currentRecord.id] === "generating" && ( - - )} - {recordStates[currentRecord.id] === "done" && ( - - )} - {recordStates[currentRecord.id] === "failed" && ( - - )} - - {recordStates[currentRecord.id] === "generating" && - `${mediaType === "image" ? "图片" : "视频"}生成中,请稍候...`} - {recordStates[currentRecord.id] === "done" && - `${mediaType === "image" ? "图片" : "视频"}生成成功`} - {recordStates[currentRecord.id] === "failed" && - `${mediaType === "image" ? "图片" : "视频"}生成失败,请重试`} - -
- )} - - {/* Completed video player */} - {recordStates[currentRecord.id] === "done" && - currentRecord.videoUrl && ( -
- - 生成视频 - -
-
-
- )} - {/* Reference thumbnails */} {currentRecord.references && currentRecord.references.length > 0 && (
diff --git a/video-gen-app/src/pages/GeneratedRecord.tsx b/video-gen-app/src/pages/GeneratedRecord.tsx index dd7ca096..17fa96fd 100644 --- a/video-gen-app/src/pages/GeneratedRecord.tsx +++ b/video-gen-app/src/pages/GeneratedRecord.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useLayoutEffect } from 'react'; -import { Button, Empty, Input, Select, Space, Typography, Tag } from 'antd'; +import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react'; +import { Button, Empty, Input, Select, Space, Typography, Tag, message } from 'antd'; import { SearchOutlined, FilterOutlined, @@ -9,10 +9,12 @@ import { FileTextOutlined, DownloadOutlined, XOutlined, + ClockCircleOutlined, } from '@ant-design/icons'; import { gethistory,gethistoryItems } from '../api'; const { Search } = Input; +const { Text } = Typography; const GeneratedRecord: React.FC = () => { const [filterType, setFilterType] = useState<'project' | 'creation'>('project'); @@ -29,6 +31,89 @@ const GeneratedRecord: React.FC = () => { const [previewItem, setPreviewItem] = useState(null); const videoRef = React.createRef(); + // 全局 Intersection Observer 实例(复用,避免创建过多实例) + let globalObserver: IntersectionObserver | null = null; + const observerCallbacks = new Map void>(); + + // 加载队列控制 - 限制同时加载的媒体数量 + // 使用优先级队列,完全在可视区的元素优先加载 + interface LoadTask { + callback: () => void; + priority: number; // 优先级:2=完全可见(立即加载),1=部分可见,0=即将进入 + element: HTMLElement; + } + const loadingQueue: LoadTask[] = []; + const MAX_CONCURRENT_LOADS = 15; // 最大同时加载数量 + const MAX_VISIBLE_LOADS = 8; // 可视区最大并发数(不受队列限制) + let currentLoads = 0; + let visibleLoads = 0; // 当前可视区加载数 + + const enqueueLoad = (callback: () => void, element: HTMLElement, isFullyVisible: boolean) => { + // 完全可见的元素立即加载,不受队列限制 + if (isFullyVisible && visibleLoads < MAX_VISIBLE_LOADS) { + visibleLoads++; + currentLoads++; + callback(); + } else if (currentLoads < MAX_CONCURRENT_LOADS) { + // 部分可见或预加载区域的元素,受队列限制 + currentLoads++; + callback(); + } else { + // 添加到优先级队列 + const task: LoadTask = { + callback, + priority: isFullyVisible ? 2 : 1, + element + }; + loadingQueue.push(task); + // 按优先级排序,高优先级在前 + loadingQueue.sort((a, b) => b.priority - a.priority); + } + }; + + const completeLoad = () => { + currentLoads--; + // 如果是可视区加载完成 + if (visibleLoads > 0) { + visibleLoads--; + } + // 处理队列中的任务 + if (loadingQueue.length > 0) { + // 优先处理高优先级任务 + const nextTask = loadingQueue.shift(); + if (nextTask) { + currentLoads++; + // 如果是完全可见的任务,计入可视区加载数 + if (nextTask.priority === 2) { + visibleLoads++; + } + nextTask.callback(); + } + } + }; + + const getGlobalObserver = (): IntersectionObserver => { + if (!globalObserver) { + globalObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const callback = observerCallbacks.get(entry.target as HTMLElement); + if (entry.isIntersecting && callback) { + observerCallbacks.delete(entry.target as HTMLElement); + globalObserver?.unobserve(entry.target); + // 判断是否完全可见(intersectionRatio >= 1) + const isFullyVisible = entry.intersectionRatio >= 1; + // 使用优先级队列控制加载 + enqueueLoad(callback, entry.target as HTMLElement, isFullyVisible); + } + }); + }, + { rootMargin: '400px', threshold: [0.01, 0.5, 1.0] } // 提前400px开始加载,多阈值检测 + ); + } + return globalObserver; + }; + // 从URL中提取exp时间戳(支持相对路径和完整URL) const extractExpTimestamp = (url: string): number | null => { if (!url) return null; @@ -63,10 +148,292 @@ const GeneratedRecord: React.FC = () => { // 检查媒体是否过期 const isMediaExpired = (url: string): boolean => { const expTimestamp = extractExpTimestamp(url); - if (!expTimestamp) return false; + if (!expTimestamp) { + return false; // 没有exp参数,视为不过期 + } const currentTimestamp = Math.floor(Date.now() / 1000); return currentTimestamp > expTimestamp; }; + + // 懒加载媒体组件 + const LazyMedia: React.FC<{ + item: any; + mediaType: 'video' | 'image'; + onClick: () => void; + }> = ({ item, mediaType, onClick }) => { + const [isLoaded, setIsLoaded] = useState(false); + const [isError, setIsError] = useState(false); + const [isExpired, setIsExpired] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const placeholderRef = useRef(null); + const mediaRef = useRef(null); + const errorTimer = useRef | null>(null); + + // 检查媒体是否过期 + useEffect(() => { + const url = mediaType === 'video' ? item.videoUrl : item.imageUrl; + if (url && isMediaExpired(url)) { + setIsExpired(true); + } + }, [item, mediaType]); + + useEffect(() => { + // 如果已过期,不需要监听 + if (isExpired) return; + + const placeholder = placeholderRef.current; + if (!placeholder) return; + + const observer = getGlobalObserver(); + const callback = () => { + setIsLoading(true); + }; + + observerCallbacks.set(placeholder, callback); + observer.observe(placeholder); + + return () => { + observerCallbacks.delete(placeholder); + observer.unobserve(placeholder); + }; + }, [isExpired]); + + // 安全拼接URL,避免双斜杠 + const buildUrl = (path: string, isImage: boolean = false): string => { + const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000"; + // 移除路径开头的斜杠(如果有) + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + // 移除baseUrl结尾的斜杠(如果有) + const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + if (isImage) { + return `${cleanBase}/static/${cleanPath}&w=300&q=50`; + } + return `${cleanBase}/${cleanPath}`; + }; + + // 处理加载完成 + const handleLoad = () => { + // 清除可能的错误延迟定时器 + if (errorTimer.current) { + clearTimeout(errorTimer.current); + } + setIsLoaded(true); + setIsLoading(false); + completeLoad(); + }; + + const handleError = () => { + // 使用防抖,只有错误持续一段时间后才显示错误状态 + errorTimer.current = setTimeout(() => { + const mediaUrl = mediaType === 'video' ? buildUrl(item.videoUrl) : buildUrl(item.imageUrl); + // console.warn(`媒体加载失败: ${mediaUrl}`, item); + setIsError(true); + setIsLoading(false); + completeLoad(); + }, 1000); // 1秒防抖延迟 + }; + + // 获取媒体URL + const mediaUrl = mediaType === 'video' ? buildUrl(item.videoUrl) : buildUrl(item.imageUrl, true); + const coverUrl = item.videoCoverUrl ? buildUrl(item.videoCoverUrl, true) : undefined; + + return ( +
{ + (e.currentTarget as HTMLElement).style.transform = 'scale(1.05)'; + (e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; + (e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; + }} + > + {/* 加载占位符 - 显示渐变背景和加载状态 */} + {!isLoading && !isLoaded && !isError && ( +
+
+ {mediaType === 'video' ? ( + + ) : ( + + )} +
+
+ )} + + {/* 视频无封面时直接显示占位符 */} + {mediaType === 'video' && !item.videoCoverUrl && !isError && ( +
+ + 暂无封面 +
+ )} + + {/* 媒体内容 - 当isLoading为true时开始渲染,加载完成后显示 */} + {((mediaType === 'video' && item.videoCoverUrl) || mediaType === 'image') && (isLoading || isLoaded) && !isError && ( +
+ {/* 加载中遮罩 */} + {isLoading && !isLoaded && ( +
+
+
+ )} + {mediaType === 'video' && item.videoCoverUrl && ( + } + src={coverUrl} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + opacity: isLoaded ? 1 : 0, + transition: 'opacity 0.3s ease-in-out' + }} + loading="lazy" + onLoad={handleLoad} + onError={handleError} + /> + )} + {mediaType === 'image' && ( + } + src={mediaUrl} + alt="图片预览" + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + opacity: isLoaded ? 1 : 0, + transition: 'opacity 0.3s ease-in-out' + }} + loading="lazy" + onLoad={handleLoad} + onError={handleError} + /> + )} +
+ )} + + {/* 过期占位符 */} + {isExpired && ( +
+ + 图片过期 +
+ )} + + {/* 错误占位符 */} + {isError && ( +
+ 加载失败 +
+ )} + + {/* 点击提示 */} + {!isExpired && ( +
{ + (e.currentTarget as HTMLElement).style.opacity = '1'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.opacity = '0'; + }} + > + 点击预览 +
+ )} +
+ ); + }; // 下载文件 const handleDownload = (item: any) => { @@ -279,7 +646,7 @@ const GeneratedRecord: React.FC = () => { : '#f8f9fc', border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0', color: filterMedia === 'video' ? '#fff' : '#64748b', - fontWeight: 600, + fontWeight: 600, }} icon={} > @@ -313,8 +680,8 @@ const GeneratedRecord: React.FC = () => { /> ) : (
- {recordlist.map((group: any) => ( -
+ {recordlist.map((group: any,index: number) => ( +
{/* Date label */}
{ gap: 8, }}> {group.items.map((item: any) => ( -
handlePreview(item)} - onMouseEnter={(e) => { - (e.currentTarget as HTMLElement).style.transform = 'scale(1.05)'; - (e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)'; - }} - onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; - (e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; - }} - > - {filterMedia === 'video' ? ( -
+ /> ))}
{/* 分组内加载更多 */} {group.total && group.total > group.items.length && (
- {/* 内容区域 - 响应式布局 */} + {/* 内容区域 */}
{/* 媒体预览 */}
{ justifyContent: 'center', minHeight: '200px', }}> - {filterMedia === 'video' ? ( + {/* 检查媒体是否过期 */} + {isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? ( +
+
⚠️
+

图片/视频资源已过期,请刷新重新加载~

+
+ ) : filterMedia === 'video' ? (
{
)} + {records.total > 0 && ( +
+ `共 ${total} 条记录`} + onChange={(page, size) => { + setCurrentPage(page); + setPageSize(size); + }} + /> +
+ )} + {/* Generate modal */} 生成视频} @@ -474,7 +501,7 @@ const RecordsPage: React.FC = () => { {genModal && (
{(() => { - const rec = records.find(r => r.id === genModal.recordId); + const rec = recordItems.find(r => r.id === genModal.recordId); return ( <>
diff --git a/video-gen-app/src/store/useAppStore.ts b/video-gen-app/src/store/useAppStore.ts index 522e349a..e78b9ea8 100644 --- a/video-gen-app/src/store/useAppStore.ts +++ b/video-gen-app/src/store/useAppStore.ts @@ -3,16 +3,23 @@ import type { Project, GenerationRecord, OptimizeParams, GenerateParams, Optimiz import * as api from '../api'; import { useAuthStore } from './useAuthStore'; +const emptyRecordsPage = (): api.GenerationRecordPageListOut => ({ + page: 1, + pageSize: 10, + total: 0, + items: [], +}); + interface AppState { projects: Project[]; - records: GenerationRecord[]; + records: api.GenerationRecordPageListOut; loading: boolean; fetchProjects: () => Promise; createProject: (name: string, industry: Industry) => Promise; deleteProject: (id: string) => Promise; - fetchRecords: (projectId?: string) => Promise; + fetchRecords: (params?: api.GetRecordsPageParams) => Promise; optimizePrompt: (projectId: string, params: OptimizeParams) => Promise; generateVideo: (recordId: string, params: GenerateParams) => Promise; updateRecordReferences: (recordId: string, references: MediaReference[]) => void; @@ -20,7 +27,7 @@ interface AppState { export const useAppStore = create((set, get) => ({ projects: [], - records: [], + records: emptyRecordsPage(), loading: false, fetchProjects: async () => { @@ -44,10 +51,10 @@ export const useAppStore = create((set, get) => ({ set({ projects: get().projects.filter((p) => p.id !== id) }); }, - fetchRecords: async (projectId) => { + fetchRecords: async (params = {}) => { set({ loading: true }); try { - const records = await api.getRecords(projectId); + const records = await api.getRecordsPage(params); set({ records, loading: false }); } catch { set({ loading: false }); @@ -57,22 +64,39 @@ export const useAppStore = create((set, get) => ({ optimizePrompt: async (projectId, params) => { const result = await api.optimizePrompt(projectId, params); try { await useAuthStore.getState().checkAuth(); } catch { /* */ } - set({ records: [result.record, ...get().records] }); + + const currentRecords = get().records; + set({ + records: { + ...currentRecords, + total: currentRecords.total + 1, + items: [result.record, ...currentRecords.items], + }, + }); return result; }, generateVideo: async (recordId, params) => { const record = await api.generateVideo(recordId, params); try { await useAuthStore.getState().checkAuth(); } catch { /* */ } + + const currentRecords = get().records; set({ - records: get().records.map((r) => (r.id === recordId ? record : r)), + records: { + ...currentRecords, + items: currentRecords.items.map((r) => (r.id === recordId ? record : r)), + }, }); return record; }, updateRecordReferences: (recordId, references) => { + const currentRecords = get().records; set({ - records: get().records.map((r) => (r.id === recordId ? { ...r, references } : r)), + records: { + ...currentRecords, + items: currentRecords.items.map((r) => (r.id === recordId ? { ...r, references } : r)), + }, }); }, })); diff --git a/video-gen-app/src/store/useAuthStore.ts b/video-gen-app/src/store/useAuthStore.ts index 0c8ae9ef..62fe9745 100644 --- a/video-gen-app/src/store/useAuthStore.ts +++ b/video-gen-app/src/store/useAuthStore.ts @@ -9,6 +9,7 @@ interface AuthState { logout: () => Promise; checkAuth: () => Promise; changePassword: (oldPwd: string, newPwd: string) => Promise; + refreshUser: () => Promise; } export const useAuthStore = create((set) => ({ @@ -42,4 +43,13 @@ export const useAuthStore = create((set) => ({ changePassword: async (oldPwd, newPwd) => { await api.changePassword(oldPwd, newPwd); }, + + refreshUser: async () => { + try { + const user = await api.getUser(); + set({ user }); + } catch (error) { + console.error('Failed to refresh user:', error); + } + }, })); diff --git a/video-gen-app/src/types/index.ts b/video-gen-app/src/types/index.ts index 50d5f7f7..84615a10 100644 --- a/video-gen-app/src/types/index.ts +++ b/video-gen-app/src/types/index.ts @@ -60,6 +60,7 @@ export interface MediaReference { export interface GenerationRecord { id: string; + items:[], projectId: string; projectName: string; originalPrompt: string;