轮询修改,媒体过期判断
This commit is contained in:
@@ -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<string | null> {
|
||||
}
|
||||
|
||||
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> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -79,6 +80,7 @@ export async function apiRequest<T>(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: <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 }),
|
||||
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 }),
|
||||
|
||||
@@ -66,10 +66,47 @@ export async function deleteProject(id: string): Promise<void> {
|
||||
|
||||
// ── Generation ────────────────────────────────────────────
|
||||
|
||||
export async function getRecords(projectId?: string): Promise<GenerationRecord[]> {
|
||||
if (USE_MOCK) return mock.mockGetGenerationRecords(projectId);
|
||||
const q = projectId ? `?project_id=${projectId}` : '';
|
||||
return api.get<GenerationRecord[]>(`/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<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(
|
||||
@@ -288,19 +325,19 @@ export async function getEngine(): Promise<any[]> {
|
||||
// ── Generation AI Tasks ────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
// 创建ai生成任务
|
||||
export async function createGenerationTask(params: any): Promise<any> {
|
||||
return api.post('/generation-ai/tasks', params);
|
||||
}
|
||||
|
||||
// 获取ai生成任务列表
|
||||
export async function getgen_list(Pagebreak: any): Promise<any[]> {
|
||||
return api.get('/generation-ai/tasks?page='+Pagebreak.page+'&page_size='+Pagebreak.pageSize);
|
||||
}
|
||||
|
||||
// 获取生成任务历史记录
|
||||
export async function gethistory(Pagebreak: any): Promise<any[]> {
|
||||
return api.get('/generation-ai/history'+Pagebreak);
|
||||
}
|
||||
|
||||
// 获取生成任务历史记录子项
|
||||
export async function gethistoryItems(Pagebreak: any): Promise<any[]> {
|
||||
return api.get('/generation-ai/history/'+Pagebreak);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -389,15 +389,17 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
{/* Floating sidebar toggle hover zone */}
|
||||
<div
|
||||
className="desktop-sidebar-toggle-zone"
|
||||
onMouseEnter={() => 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 && (
|
||||
<div onClick={() => setCollapsed(!collapsed)} style={{
|
||||
<div onClick={() => setCollapsed(prev => !prev)} style={{
|
||||
position: 'absolute', left: '50%', top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
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 { 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<Notification[]>([]);
|
||||
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 */ }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<any>(null);
|
||||
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)
|
||||
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<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) => {
|
||||
@@ -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={<VideoCameraOutlined />}
|
||||
>
|
||||
@@ -313,8 +680,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
{recordlist.map((group: any) => (
|
||||
<div key={group.date} style={{ marginBottom: 32 }}>
|
||||
{recordlist.map((group: any,index: number) => (
|
||||
<div key={index} style={{ marginBottom: 32 }}>
|
||||
{/* Date label */}
|
||||
<div style={{
|
||||
fontSize: 14,
|
||||
@@ -332,72 +699,21 @@ const GeneratedRecord: React.FC = () => {
|
||||
gap: 8,
|
||||
}}>
|
||||
{group.items.map((item: any) => (
|
||||
<div
|
||||
<LazyMedia
|
||||
key={item.id}
|
||||
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',
|
||||
}}
|
||||
item={item}
|
||||
mediaType={filterMedia}
|
||||
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>
|
||||
{/* 分组内加载更多 */}
|
||||
{group.total && group.total > group.items.length && (
|
||||
<div style={{ padding: '12px 0', textAlign: 'left' }}>
|
||||
<Button
|
||||
onClick={() => handleGroupLoadMore(group.generatedDate,group.items, group.page)}
|
||||
loading={loadingGroups.has(group.date)}
|
||||
disabled={loadingGroups.has(group.date)}
|
||||
onClick={() => handleGroupLoadMore(group.generatedDate, group.items, group.page)}
|
||||
loading={loadingGroups.has(group.generatedDate)}
|
||||
disabled={loadingGroups.has(group.generatedDate)}
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
@@ -496,16 +812,17 @@ const GeneratedRecord: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 - 响应式布局 */}
|
||||
{/* 内容区域 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
height: '500px',
|
||||
gap: 20,
|
||||
padding: 20,
|
||||
overflow: 'auto',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
{/* 媒体预览 */}
|
||||
<div style={{
|
||||
@@ -517,7 +834,13 @@ const GeneratedRecord: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
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
|
||||
ref={videoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl}`}
|
||||
@@ -532,7 +855,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<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="预览"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -757,6 +1080,16 @@ function formatFileSize(bytes: number): string {
|
||||
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 {
|
||||
if (!dateString) return '-';
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
@@ -41,6 +42,9 @@ const statusConfig: Record<GenerationStatus, { color: string; text: string; icon
|
||||
|
||||
const RecordsPage: React.FC = () => {
|
||||
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 [filterProject, setFilterProject] = useState<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
@@ -50,7 +54,18 @@ const RecordsPage: React.FC = () => {
|
||||
// Generate modal for param selection
|
||||
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 () => {
|
||||
if (!genModal) return;
|
||||
@@ -94,11 +109,7 @@ const RecordsPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = records.filter((r) => {
|
||||
if (filterProject && r.projectId !== filterProject) return false;
|
||||
if (filterStatus && r.status !== filterStatus) return false;
|
||||
return true;
|
||||
});
|
||||
const filtered = recordItems;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -117,11 +128,11 @@ const RecordsPage: React.FC = () => {
|
||||
<Space>
|
||||
<FilterOutlined style={{ color: '#94a3b8' }} />
|
||||
<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>)}
|
||||
</Select>
|
||||
<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="generating">生成中</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
@@ -129,7 +140,7 @@ const RecordsPage: React.FC = () => {
|
||||
</Select>
|
||||
</Space>
|
||||
<Typography.Text style={{ marginLeft: 'auto', lineHeight: '32px', color: '#94a3b8', fontSize: 13 }}>
|
||||
共 {filtered.length} 条记录
|
||||
共 {records.total} 条记录
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -409,7 +420,7 @@ const RecordsPage: React.FC = () => {
|
||||
src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.imageUrl}`}
|
||||
alt={record.projectName}
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -461,6 +472,22 @@ const RecordsPage: React.FC = () => {
|
||||
</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 */}
|
||||
<Modal
|
||||
title={<Space><RocketOutlined />生成视频</Space>}
|
||||
@@ -474,7 +501,7 @@ const RecordsPage: React.FC = () => {
|
||||
{genModal && (
|
||||
<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 (
|
||||
<>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
|
||||
@@ -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<void>;
|
||||
createProject: (name: string, industry: Industry) => Promise<Project>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
|
||||
fetchRecords: (projectId?: string) => Promise<void>;
|
||||
fetchRecords: (params?: api.GetRecordsPageParams) => Promise<void>;
|
||||
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
|
||||
generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>;
|
||||
updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
|
||||
@@ -20,7 +27,7 @@ interface AppState {
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
projects: [],
|
||||
records: [],
|
||||
records: emptyRecordsPage(),
|
||||
loading: false,
|
||||
|
||||
fetchProjects: async () => {
|
||||
@@ -44,10 +51,10 @@ export const useAppStore = create<AppState>((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<AppState>((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)),
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -9,6 +9,7 @@ interface AuthState {
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
changePassword: (oldPwd: string, newPwd: string) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
@@ -42,4 +43,13 @@ export const useAuthStore = create<AuthState>((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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface MediaReference {
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
items:[],
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
originalPrompt: string;
|
||||
|
||||
Reference in New Issue
Block a user