轮询修改,媒体过期判断

This commit is contained in:
孙佳艺
2026-06-04 13:03:04 +08:00
parent 64f7069ae2
commit 450e509b1a
13 changed files with 1812 additions and 678 deletions
+7 -2
View File
@@ -13,6 +13,7 @@ interface RequestOptions {
body?: unknown; body?: unknown;
auth?: boolean; auth?: boolean;
encryptBody?: boolean; encryptBody?: boolean;
signal?: AbortSignal;
} }
/** Convert snake_case string to camelCase */ /** Convert snake_case string to camelCase */
@@ -53,7 +54,7 @@ async function tryDecrypt(data: string): Promise<string | null> {
} }
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> { export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options; const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal } = options;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -79,6 +80,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
method, method,
headers, headers,
body: bodyStr, body: bodyStr,
signal,
}); });
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
@@ -120,7 +122,10 @@ if (!res.ok) {
// Convenience methods // Convenience methods
export const api = { export const api = {
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }), get: <T>(path: string, options: boolean | { auth?: boolean; signal?: AbortSignal } = true) => {
const opts = typeof options === 'boolean' ? { auth: options } : options;
return apiRequest<T>(path, opts);
},
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }), post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }),
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }), put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }), delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
+45 -8
View File
@@ -66,10 +66,47 @@ export async function deleteProject(id: string): Promise<void> {
// ── Generation ──────────────────────────────────────────── // ── Generation ────────────────────────────────────────────
export async function getRecords(projectId?: string): Promise<GenerationRecord[]> { export interface GenerationRecordPageListOut {
if (USE_MOCK) return mock.mockGetGenerationRecords(projectId); page: number;
const q = projectId ? `?project_id=${projectId}` : ''; pageSize: number;
return api.get<GenerationRecord[]>(`/generation-records${q}`); total: number;
items: GenerationRecord[];
}
export interface GetRecordsPageParams {
projectId?: string;
status?: string;
page?: number;
pageSize?: number;
signal?: AbortSignal;
}
export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise<GenerationRecordPageListOut> {
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<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`, { signal: params.signal });
} }
export async function optimizePrompt( export async function optimizePrompt(
@@ -288,19 +325,19 @@ export async function getEngine(): Promise<any[]> {
// ── Generation AI Tasks ──────────────────────────────────── // ── Generation AI Tasks ────────────────────────────────────
// 创建ai生成任务
export async function createGenerationTask(params: any): Promise<any> { export async function createGenerationTask(params: any): Promise<any> {
return api.post('/generation-ai/tasks', params); return api.post('/generation-ai/tasks', params);
} }
// 获取ai生成任务列表
export async function getgen_list(Pagebreak: any): Promise<any[]> { export async function getgen_list(Pagebreak: any): Promise<any[]> {
return api.get('/generation-ai/tasks?page='+Pagebreak.page+'&page_size='+Pagebreak.pageSize); return api.get('/generation-ai/tasks?page='+Pagebreak.page+'&page_size='+Pagebreak.pageSize);
} }
// 获取生成任务历史记录
export async function gethistory(Pagebreak: any): Promise<any[]> { export async function gethistory(Pagebreak: any): Promise<any[]> {
return api.get('/generation-ai/history'+Pagebreak); return api.get('/generation-ai/history'+Pagebreak);
} }
// 获取生成任务历史记录子项
export async function gethistoryItems(Pagebreak: any): Promise<any[]> { export async function gethistoryItems(Pagebreak: any): Promise<any[]> {
return api.get('/generation-ai/history/'+Pagebreak); return api.get('/generation-ai/history/'+Pagebreak);
} }
+6
View File
@@ -47,6 +47,7 @@ let MOCK_PROJECTS: Project[] = [
let MOCK_RECORDS: GenerationRecord[] = [ let MOCK_RECORDS: GenerationRecord[] = [
{ {
id: 'r-1', id: 'r-1',
items: [],
projectId: 'p-1', projectId: 'p-1',
projectName: '618电商大促宣传片', projectName: '618电商大促宣传片',
originalPrompt: '一个年轻女性在时尚直播间推荐夏季新款连衣裙,背景明亮温馨', originalPrompt: '一个年轻女性在时尚直播间推荐夏季新款连衣裙,背景明亮温馨',
@@ -69,6 +70,7 @@ let MOCK_RECORDS: GenerationRecord[] = [
}, },
{ {
id: 'r-2', id: 'r-2',
items: [],
projectId: 'p-2', projectId: 'p-2',
projectName: '在线课程推广视频', projectName: '在线课程推广视频',
originalPrompt: '学生在明亮的教室里用平板电脑学习编程课程', originalPrompt: '学生在明亮的教室里用平板电脑学习编程课程',
@@ -91,7 +93,9 @@ let MOCK_RECORDS: GenerationRecord[] = [
}, },
{ {
id: 'r-3', id: 'r-3',
items: [],
projectId: 'p-1', projectId: 'p-1',
projectName: '618电商大促宣传片', projectName: '618电商大促宣传片',
originalPrompt: '多个快递包裹从仓库货架上飞出,物流车快速配送', originalPrompt: '多个快递包裹从仓库货架上飞出,物流车快速配送',
optimizedPrompt: '高速摄影风格,镜头从大型智能仓储中心内部开始,自动化机械臂精准抓取印有品牌Logo的快递包裹。包裹沿传送带高速移动,在分拣中心精准落入对应区域。画面切换至无人机和无人配送车在城市街道上进行最后一公里配送。最终画面定格在消费者微笑签收包裹的瞬间。整体采用蓝色科技感色调,快节奏剪辑。', optimizedPrompt: '高速摄影风格,镜头从大型智能仓储中心内部开始,自动化机械臂精准抓取印有品牌Logo的快递包裹。包裹沿传送带高速移动,在分拣中心精准落入对应区域。画面切换至无人机和无人配送车在城市街道上进行最后一公里配送。最终画面定格在消费者微笑签收包裹的瞬间。整体采用蓝色科技感色调,快节奏剪辑。',
@@ -111,6 +115,7 @@ let MOCK_RECORDS: GenerationRecord[] = [
}, },
{ {
id: 'r-4', id: 'r-4',
items: [],
projectId: 'p-3', projectId: 'p-3',
projectName: '新游戏预告片', projectName: '新游戏预告片',
originalPrompt: '一个奇幻世界里的魔法城堡,龙在天空飞过', originalPrompt: '一个奇幻世界里的魔法城堡,龙在天空飞过',
@@ -217,6 +222,7 @@ export async function mockOptimizePrompt(
const record: GenerationRecord = { const record: GenerationRecord = {
id: `r-${Date.now()}`, id: `r-${Date.now()}`,
projectId, projectId,
items: [],
projectName: project?.name ?? '未知项目', projectName: project?.name ?? '未知项目',
originalPrompt: params.prompt, originalPrompt: params.prompt,
optimizedPrompt, optimizedPrompt,
@@ -389,15 +389,17 @@ const AppLayout: React.FC = () => {
{/* Floating sidebar toggle hover zone */} {/* Floating sidebar toggle hover zone */}
<div <div
className="desktop-sidebar-toggle-zone"
onMouseEnter={() => setToggleHover(true)} onMouseEnter={() => setToggleHover(true)}
onMouseLeave={() => setToggleHover(false)} onMouseLeave={() => setToggleHover(false)}
style={{ style={{
position: 'fixed', left: sidebarW - 16, top: 0, bottom: 0, position: 'fixed', left: sidebarW - 16, top: 0, bottom: 0,
zIndex: 110, width: 32, cursor: 'default', zIndex: 110, width: 32, cursor: 'default',
transition: 'left 0.25s ease',
}} }}
> >
{toggleHover && ( {toggleHover && (
<div onClick={() => setCollapsed(!collapsed)} style={{ <div onClick={() => setCollapsed(prev => !prev)} style={{
position: 'absolute', left: '50%', top: '50%', position: 'absolute', left: '50%', top: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: 24, height: 56, borderRadius: '0 8px 8px 0', width: 24, height: 56, borderRadius: '0 8px 8px 0',
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { Tag, Typography, Button } from 'antd'; import { Tag, Typography, Button } from 'antd';
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons'; import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
import { getNotifications, markNotificationRead } from '../api'; import { getNotifications, markNotificationRead } from '../api';
import { useAuthStore } from '../store/useAuthStore';
interface Notification { interface Notification {
id: string; id: string;
@@ -22,6 +23,7 @@ const NotificationPopup: React.FC = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const refreshUser = useAuthStore((state) => state.refreshUser);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
try { try {
@@ -52,6 +54,8 @@ const NotificationPopup: React.FC = () => {
setVisible(false); setVisible(false);
setNotifications([]); setNotifications([]);
setCurrentIndex(0); setCurrentIndex(0);
// 刷新用户信息(包括积分)
try { await refreshUser(); } catch { /* ignore */ }
} }
}; };
+7 -4
View File
@@ -146,6 +146,7 @@ html, body {
@media (max-width: 768px) { @media (max-width: 768px) {
.desktop-sidebar { display: none !important; } .desktop-sidebar { display: none !important; }
.desktop-content { margin-left: 0 !important; padding: 16px !important; padding-bottom: 80px !important; overflow-x: hidden !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; } .mobile-bottom-nav { display: flex !important; }
/* Prevent horizontal overflow globally */ /* Prevent horizontal overflow globally */
@@ -319,11 +320,13 @@ html, body {
.mobile-mb-12 { margin-bottom: 12px !important; } .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) { @media (min-width: 769px) and (max-width: 1024px) {
/* Desktop sidebar adjustments */ /*
.desktop-sidebar { width: 180px !important; } 左侧栏宽度必须统一由 AppLayout.tsx 的 sidebarW 控制。
.desktop-content { margin-left: 180px !important; } 不要在这里覆盖 .desktop-sidebar width 或 .desktop-content margin-left
否则展开/折叠状态会和真实布局宽度不一致。
*/
/* Card max-width */ /* Card max-width */
.ant-card { max-width: calc(50% - 8px) !important; } .ant-card { max-width: calc(50% - 8px) !important; }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+401 -68
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useLayoutEffect } from 'react'; import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
import { Button, Empty, Input, Select, Space, Typography, Tag } from 'antd'; import { Button, Empty, Input, Select, Space, Typography, Tag, message } from 'antd';
import { import {
SearchOutlined, SearchOutlined,
FilterOutlined, FilterOutlined,
@@ -9,10 +9,12 @@ import {
FileTextOutlined, FileTextOutlined,
DownloadOutlined, DownloadOutlined,
XOutlined, XOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { gethistory,gethistoryItems } from '../api'; import { gethistory,gethistoryItems } from '../api';
const { Search } = Input; const { Search } = Input;
const { Text } = Typography;
const GeneratedRecord: React.FC = () => { const GeneratedRecord: React.FC = () => {
const [filterType, setFilterType] = useState<'project' | 'creation'>('project'); const [filterType, setFilterType] = useState<'project' | 'creation'>('project');
@@ -29,6 +31,89 @@ const GeneratedRecord: React.FC = () => {
const [previewItem, setPreviewItem] = useState<any>(null); const [previewItem, setPreviewItem] = useState<any>(null);
const videoRef = React.createRef<HTMLVideoElement>(); const videoRef = React.createRef<HTMLVideoElement>();
// 全局 Intersection Observer 实例(复用,避免创建过多实例)
let globalObserver: IntersectionObserver | null = null;
const observerCallbacks = new Map<HTMLElement, () => 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) // 从URL中提取exp时间戳(支持相对路径和完整URL)
const extractExpTimestamp = (url: string): number | null => { const extractExpTimestamp = (url: string): number | null => {
if (!url) return null; if (!url) return null;
@@ -63,10 +148,292 @@ const GeneratedRecord: React.FC = () => {
// 检查媒体是否过期 // 检查媒体是否过期
const isMediaExpired = (url: string): boolean => { const isMediaExpired = (url: string): boolean => {
const expTimestamp = extractExpTimestamp(url); const expTimestamp = extractExpTimestamp(url);
if (!expTimestamp) return false; if (!expTimestamp) {
return false; // 没有exp参数,视为不过期
}
const currentTimestamp = Math.floor(Date.now() / 1000); const currentTimestamp = Math.floor(Date.now() / 1000);
return currentTimestamp > expTimestamp; 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<HTMLDivElement>(null);
const mediaRef = useRef<HTMLImageElement | HTMLVideoElement>(null);
const errorTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={placeholderRef}
style={{
width: 160,
height: 120,
borderRadius: 4,
overflow: 'hidden',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
transition: 'transform 0.2s, box-shadow 0.2s',
position: 'relative',
backgroundColor: '#f1f5f9',
}}
onClick={onClick}
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)';
}}
>
{/* 加载占位符 - 显示渐变背景和加载状态 */}
{!isLoading && !isLoaded && !isError && (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
}}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#cbd5e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{mediaType === 'video' ? (
<VideoCameraOutlined style={{ color: '#64748b', fontSize: 16 }} />
) : (
<PictureOutlined style={{ color: '#64748b', fontSize: 16 }} />
)}
</div>
</div>
)}
{/* 视频无封面时直接显示占位符 */}
{mediaType === 'video' && !item.videoCoverUrl && !isError && (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1e293b',
}}>
<VideoCameraOutlined style={{ color: '#64748b', fontSize: 24 }} />
<Text style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}></Text>
</div>
)}
{/* 媒体内容 - 当isLoading为true时开始渲染,加载完成后显示 */}
{((mediaType === 'video' && item.videoCoverUrl) || mediaType === 'image') && (isLoading || isLoaded) && !isError && (
<div style={{
width: '100%',
height: '100%',
position: 'relative',
}}>
{/* 加载中遮罩 */}
{isLoading && !isLoaded && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(248, 250, 252, 0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
}}>
<div style={{
width: 28,
height: 28,
border: '3px solid #e2e8f0',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}} />
</div>
)}
{mediaType === 'video' && item.videoCoverUrl && (
<img
ref={mediaRef as React.RefObject<HTMLImageElement>}
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' && (
<img
ref={mediaRef as React.RefObject<HTMLImageElement>}
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}
/>
)}
</div>
)}
{/* 过期占位符 */}
{isExpired && (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fef3c7',
color: '#d97706',
fontSize: 12,
flexDirection: 'column',
gap: 4,
}}>
<ClockCircleOutlined style={{ fontSize: 24 }} />
</div>
)}
{/* 错误占位符 */}
{isError && (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fef2f2',
color: '#dc2626',
fontSize: 12,
}}>
</div>
)}
{/* 点击提示 */}
{!isExpired && (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(transparent, rgba(0,0,0,0.5))',
padding: '8px',
color: '#fff',
fontSize: 12,
opacity: 0,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.opacity = '1';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.opacity = '0';
}}
>
</div>
)}
</div>
);
};
// 下载文件 // 下载文件
const handleDownload = (item: any) => { const handleDownload = (item: any) => {
@@ -279,7 +646,7 @@ const GeneratedRecord: React.FC = () => {
: '#f8f9fc', : '#f8f9fc',
border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0', border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0',
color: filterMedia === 'video' ? '#fff' : '#64748b', color: filterMedia === 'video' ? '#fff' : '#64748b',
fontWeight: 600, fontWeight: 600,
}} }}
icon={<VideoCameraOutlined />} icon={<VideoCameraOutlined />}
> >
@@ -313,8 +680,8 @@ const GeneratedRecord: React.FC = () => {
/> />
) : ( ) : (
<div style={{ padding: '0 4px' }}> <div style={{ padding: '0 4px' }}>
{recordlist.map((group: any) => ( {recordlist.map((group: any,index: number) => (
<div key={group.date} style={{ marginBottom: 32 }}> <div key={index} style={{ marginBottom: 32 }}>
{/* Date label */} {/* Date label */}
<div style={{ <div style={{
fontSize: 14, fontSize: 14,
@@ -332,72 +699,21 @@ const GeneratedRecord: React.FC = () => {
gap: 8, gap: 8,
}}> }}>
{group.items.map((item: any) => ( {group.items.map((item: any) => (
<div <LazyMedia
key={item.id} key={item.id}
style={{ item={item}
width: 160, mediaType={filterMedia}
height: 120,
borderRadius: 4,
overflow: 'hidden',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onClick={() => handlePreview(item)} onClick={() => 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' ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.videoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.imageUrl}`}
alt="图片预览"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
{/* 点击提示 */}
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(transparent, rgba(0,0,0,0.5))',
padding: '8px',
color: '#fff',
fontSize: 12,
opacity: 0,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.opacity = '1';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.opacity = '0';
}}
>
</div>
</div>
))} ))}
</div> </div>
{/* 分组内加载更多 */} {/* 分组内加载更多 */}
{group.total && group.total > group.items.length && ( {group.total && group.total > group.items.length && (
<div style={{ padding: '12px 0', textAlign: 'left' }}> <div style={{ padding: '12px 0', textAlign: 'left' }}>
<Button <Button
onClick={() => handleGroupLoadMore(group.generatedDate,group.items, group.page)} onClick={() => handleGroupLoadMore(group.generatedDate, group.items, group.page)}
loading={loadingGroups.has(group.date)} loading={loadingGroups.has(group.generatedDate)}
disabled={loadingGroups.has(group.date)} disabled={loadingGroups.has(group.generatedDate)}
size="small" size="small"
style={{ style={{
borderRadius: 6, borderRadius: 6,
@@ -496,16 +812,17 @@ const GeneratedRecord: React.FC = () => {
/> />
</div> </div>
{/* 内容区域 - 响应式布局 */} {/* 内容区域 */}
<div style={{ <div style={{
flex: 1, flex: 1,
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
height: '500px',
gap: 20, gap: 20,
padding: 20, padding: 20,
overflow: 'auto', overflow: 'auto',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'flex-start', alignItems: 'center',
}}> }}>
{/* 媒体预览 */} {/* 媒体预览 */}
<div style={{ <div style={{
@@ -517,7 +834,13 @@ const GeneratedRecord: React.FC = () => {
justifyContent: 'center', justifyContent: 'center',
minHeight: '200px', minHeight: '200px',
}}> }}>
{filterMedia === 'video' ? ( {/* 检查媒体是否过期 */}
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<p style={{ fontSize: 16, color: '#ff4d4f', marginBottom: 16 }}>/</p>
</div>
) : filterMedia === 'video' ? (
<video <video
ref={videoRef} ref={videoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl}`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl}`}
@@ -532,7 +855,7 @@ const GeneratedRecord: React.FC = () => {
/> />
) : ( ) : (
<img <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.imageUrl}`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewItem.imageUrl}&w=300&q=50`}
alt="预览" alt="预览"
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
@@ -757,6 +1080,16 @@ function formatFileSize(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
// 添加旋转动画样式
const styleSheet = document.createElement('style');
styleSheet.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(styleSheet);
// 日期格式化(年月日时分秒) // 日期格式化(年月日时分秒)
function formatDateTime(dateString: string): string { function formatDateTime(dateString: string): string {
if (!dateString) return '-'; if (!dateString) return '-';
+38 -11
View File
@@ -5,6 +5,7 @@ import {
Input, Input,
message, message,
Modal, Modal,
Pagination,
Select, Select,
Space, Space,
Tag, Tag,
@@ -41,6 +42,9 @@ const statusConfig: Record<GenerationStatus, { color: string; text: string; icon
const RecordsPage: React.FC = () => { const RecordsPage: React.FC = () => {
const { records, projects, fetchRecords, fetchProjects, generateVideo } = useAppStore(); const { records, projects, fetchRecords, fetchProjects, generateVideo } = useAppStore();
const recordItems = records.items;
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [generating, setGenerating] = useState<Record<string, boolean>>({}); const [generating, setGenerating] = useState<Record<string, boolean>>({});
const [filterProject, setFilterProject] = useState<string | undefined>(undefined); const [filterProject, setFilterProject] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
@@ -50,7 +54,18 @@ const RecordsPage: React.FC = () => {
// Generate modal for param selection // Generate modal for param selection
const [genModal, setGenModal] = useState<{ recordId: string; projectName: string; ratio: AspectRatio; resolution: Resolution } | null>(null); const [genModal, setGenModal] = useState<{ recordId: string; projectName: string; ratio: AspectRatio; resolution: Resolution } | null>(null);
useEffect(() => { fetchRecords(); fetchProjects(); }, []); useEffect(() => {
fetchProjects();
}, [fetchProjects]);
useEffect(() => {
fetchRecords({
projectId: filterProject,
status: filterStatus,
page: currentPage,
pageSize,
});
}, [fetchRecords, filterProject, filterStatus, currentPage, pageSize]);
const handleGenerate = async () => { const handleGenerate = async () => {
if (!genModal) return; if (!genModal) return;
@@ -94,11 +109,7 @@ const RecordsPage: React.FC = () => {
}); });
}; };
const filtered = records.filter((r) => { const filtered = recordItems;
if (filterProject && r.projectId !== filterProject) return false;
if (filterStatus && r.status !== filterStatus) return false;
return true;
});
return ( return (
<div> <div>
@@ -117,11 +128,11 @@ const RecordsPage: React.FC = () => {
<Space> <Space>
<FilterOutlined style={{ color: '#94a3b8' }} /> <FilterOutlined style={{ color: '#94a3b8' }} />
<Select placeholder="全部项目" allowClear style={{ width: 200 }} <Select placeholder="全部项目" allowClear style={{ width: 200 }}
onChange={(v) => setFilterProject(v)}> onChange={(v) => { setFilterProject(v); setCurrentPage(1); }}>
{projects.map((p) => <Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>)} {projects.map((p) => <Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>)}
</Select> </Select>
<Select placeholder="全部状态" allowClear style={{ width: 140 }} <Select placeholder="全部状态" allowClear style={{ width: 140 }}
onChange={(v) => setFilterStatus(v)}> onChange={(v) => { setFilterStatus(v); setCurrentPage(1); }}>
<Select.Option value="prompt_optimized"></Select.Option> <Select.Option value="prompt_optimized"></Select.Option>
<Select.Option value="generating"></Select.Option> <Select.Option value="generating"></Select.Option>
<Select.Option value="completed"></Select.Option> <Select.Option value="completed"></Select.Option>
@@ -129,7 +140,7 @@ const RecordsPage: React.FC = () => {
</Select> </Select>
</Space> </Space>
<Typography.Text style={{ marginLeft: 'auto', lineHeight: '32px', color: '#94a3b8', fontSize: 13 }}> <Typography.Text style={{ marginLeft: 'auto', lineHeight: '32px', color: '#94a3b8', fontSize: 13 }}>
{filtered.length} {records.total}
</Typography.Text> </Typography.Text>
</div> </div>
@@ -409,7 +420,7 @@ const RecordsPage: React.FC = () => {
src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.imageUrl}`} src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.imageUrl}`}
alt={record.projectName} alt={record.projectName}
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', display: 'block' }} style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', display: 'block' }}
/> />
)} )}
</div> </div>
<div style={{ <div style={{
@@ -461,6 +472,22 @@ const RecordsPage: React.FC = () => {
</div> </div>
)} )}
{records.total > 0 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 18 }}>
<Pagination
current={records.page || currentPage}
pageSize={records.pageSize || pageSize}
total={records.total}
showSizeChanger
showTotal={(total) => `${total} 条记录`}
onChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
}}
/>
</div>
)}
{/* Generate modal */} {/* Generate modal */}
<Modal <Modal
title={<Space><RocketOutlined /></Space>} title={<Space><RocketOutlined /></Space>}
@@ -474,7 +501,7 @@ const RecordsPage: React.FC = () => {
{genModal && ( {genModal && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
{(() => { {(() => {
const rec = records.find(r => r.id === genModal.recordId); const rec = recordItems.find(r => r.id === genModal.recordId);
return ( return (
<> <>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}> <div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
+32 -8
View File
@@ -3,16 +3,23 @@ import type { Project, GenerationRecord, OptimizeParams, GenerateParams, Optimiz
import * as api from '../api'; import * as api from '../api';
import { useAuthStore } from './useAuthStore'; import { useAuthStore } from './useAuthStore';
const emptyRecordsPage = (): api.GenerationRecordPageListOut => ({
page: 1,
pageSize: 10,
total: 0,
items: [],
});
interface AppState { interface AppState {
projects: Project[]; projects: Project[];
records: GenerationRecord[]; records: api.GenerationRecordPageListOut;
loading: boolean; loading: boolean;
fetchProjects: () => Promise<void>; fetchProjects: () => Promise<void>;
createProject: (name: string, industry: Industry) => Promise<Project>; createProject: (name: string, industry: Industry) => Promise<Project>;
deleteProject: (id: string) => Promise<void>; deleteProject: (id: string) => Promise<void>;
fetchRecords: (projectId?: string) => Promise<void>; fetchRecords: (params?: api.GetRecordsPageParams) => Promise<void>;
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>; optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>; generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>;
updateRecordReferences: (recordId: string, references: MediaReference[]) => void; updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
@@ -20,7 +27,7 @@ interface AppState {
export const useAppStore = create<AppState>((set, get) => ({ export const useAppStore = create<AppState>((set, get) => ({
projects: [], projects: [],
records: [], records: emptyRecordsPage(),
loading: false, loading: false,
fetchProjects: async () => { fetchProjects: async () => {
@@ -44,10 +51,10 @@ export const useAppStore = create<AppState>((set, get) => ({
set({ projects: get().projects.filter((p) => p.id !== id) }); set({ projects: get().projects.filter((p) => p.id !== id) });
}, },
fetchRecords: async (projectId) => { fetchRecords: async (params = {}) => {
set({ loading: true }); set({ loading: true });
try { try {
const records = await api.getRecords(projectId); const records = await api.getRecordsPage(params);
set({ records, loading: false }); set({ records, loading: false });
} catch { } catch {
set({ loading: false }); set({ loading: false });
@@ -57,22 +64,39 @@ export const useAppStore = create<AppState>((set, get) => ({
optimizePrompt: async (projectId, params) => { optimizePrompt: async (projectId, params) => {
const result = await api.optimizePrompt(projectId, params); const result = await api.optimizePrompt(projectId, params);
try { await useAuthStore.getState().checkAuth(); } catch { /* */ } 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; return result;
}, },
generateVideo: async (recordId, params) => { generateVideo: async (recordId, params) => {
const record = await api.generateVideo(recordId, params); const record = await api.generateVideo(recordId, params);
try { await useAuthStore.getState().checkAuth(); } catch { /* */ } try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
const currentRecords = get().records;
set({ 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; return record;
}, },
updateRecordReferences: (recordId, references) => { updateRecordReferences: (recordId, references) => {
const currentRecords = get().records;
set({ 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)),
},
}); });
}, },
})); }));
+10
View File
@@ -9,6 +9,7 @@ interface AuthState {
logout: () => Promise<void>; logout: () => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
changePassword: (oldPwd: string, newPwd: string) => Promise<void>; changePassword: (oldPwd: string, newPwd: string) => Promise<void>;
refreshUser: () => Promise<void>;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
@@ -42,4 +43,13 @@ export const useAuthStore = create<AuthState>((set) => ({
changePassword: async (oldPwd, newPwd) => { changePassword: async (oldPwd, newPwd) => {
await api.changePassword(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);
}
},
})); }));
+1
View File
@@ -60,6 +60,7 @@ export interface MediaReference {
export interface GenerationRecord { export interface GenerationRecord {
id: string; id: string;
items:[],
projectId: string; projectId: string;
projectName: string; projectName: string;
originalPrompt: string; originalPrompt: string;