2127 lines
99 KiB
TypeScript
2127 lines
99 KiB
TypeScript
import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
|
||
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Modal, Table, DatePicker } from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import JSZip from 'jszip';
|
||
import {
|
||
FilterOutlined,
|
||
VideoCameraOutlined,
|
||
PictureOutlined,
|
||
FolderOpenOutlined,
|
||
FileTextOutlined,
|
||
DownloadOutlined,
|
||
XOutlined,
|
||
ClockCircleOutlined,
|
||
UploadOutlined,
|
||
|
||
} from '@ant-design/icons';
|
||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory } from '../api';
|
||
|
||
const { Search } = Input;
|
||
const { Text } = Typography;
|
||
|
||
const GeneratedRecord: React.FC = () => {
|
||
const [filterType, setFilterType] = useState<'project' | 'creation'>('project');
|
||
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
|
||
const [recordlist, setRecordList] = useState<any[]>([]);
|
||
const [Pagebreak, setPagebreak] = useState<any>({
|
||
page: 1,
|
||
pageSize: 10,
|
||
});
|
||
const [Totalnumber, setTotalnumber] = useState<number>(0);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [loadingGroups, setLoadingGroups] = useState<Set<string>>(new Set());
|
||
const [previewVisible, setPreviewVisible] = useState(false);
|
||
const [previewItem, setPreviewItem] = useState<any>(null);
|
||
const videoRef = React.createRef<HTMLVideoElement>();
|
||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
// 上传配置弹窗相关状态
|
||
const [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
|
||
const [accountIdList, setAccountIdList] = useState<{
|
||
accountId: string;
|
||
}[]>([]);
|
||
const [accountIdInput, setAccountIdInput] = useState('');
|
||
|
||
const [oauthList, setOauthList] = useState<any[]>([]);
|
||
const [oauthLoading, setOauthLoading] = useState(false);
|
||
const [oauthTotal, setOauthTotal] = useState(0);
|
||
const [selectedOauthItems, setSelectedOauthItems] = useState<{ value: string; label: string } | undefined>(undefined);
|
||
const [materialFileNames, setMaterialFileNames] = useState<Map<string, string>>(new Map());
|
||
const [unifiedFileName, setUnifiedFileName] = useState('');
|
||
const updateFilenameDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const [oauthPage, setOauthPage] = useState(1);
|
||
const [oauthPageSize, setOauthPageSize] = useState(10);
|
||
const [oauthSelectOpen, setOauthSelectOpen] = useState(false);
|
||
|
||
// 上传任务历史弹窗相关状态
|
||
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
|
||
const [uploadHistoryList, setUploadHistoryList] = useState<any[]>([]);
|
||
const [uploadHistoryTotal, setUploadHistoryTotal] = useState(0);
|
||
const [uploadHistoryPage, setUploadHistoryPage] = useState(1);
|
||
const [uploadHistoryPageSize, setUploadHistoryPageSize] = useState(10);
|
||
const [uploadHistoryLoading, setUploadHistoryLoading] = useState(false);
|
||
const [uploadHistoryStatus, setUploadHistoryStatus] = useState<string>('');
|
||
|
||
// 多选相关状态
|
||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||
|
||
// 全局 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;
|
||
|
||
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 LazyMedia: React.FC<{
|
||
item: any;
|
||
mediaType: 'video' | 'image';
|
||
onClick: () => void;
|
||
isSelected?: boolean;
|
||
onToggleSelect?: (itemId: string) => void;
|
||
isSelectionMode?: boolean;
|
||
}> = ({ item, mediaType, onClick, isSelected = false, onToggleSelect, isSelectionMode = false }) => {
|
||
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,
|
||
position: 'relative',
|
||
borderRadius: 4,
|
||
cursor: 'pointer',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||
}}
|
||
onClick={!isSelectionMode ? onClick : undefined}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelectionMode) {
|
||
(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) => {
|
||
if (!isSelectionMode) {
|
||
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
|
||
(e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||
}
|
||
}}
|
||
>
|
||
|
||
{/* 媒体内容 - 当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>
|
||
)}
|
||
|
||
{/* 加载占位符 - 显示渐变背景和加载状态 */}
|
||
{!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>
|
||
)}
|
||
|
||
|
||
{/* 过期占位符 */}
|
||
{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 && !isSelectionMode && (
|
||
<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>
|
||
)}
|
||
|
||
{/* 选择模式下的选择框 */}
|
||
{isSelectionMode && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
right: 8,
|
||
width: 20,
|
||
height: 20,
|
||
borderRadius: '50%',
|
||
backgroundColor: isSelected ? '#10b981' : 'rgba(255,255,255,0.9)',
|
||
border: isSelected ? '2px solid #10b981' : '2px solid #d1d5db',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
zIndex: 10,
|
||
transition: 'all 0.2s',
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onToggleSelect?.(item.generatedResourceId || item.id);
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.transform = 'scale(1.1)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.transform = 'scale(1)';
|
||
}}
|
||
>
|
||
{isSelected && (
|
||
<svg width={12} height={12} viewBox="0 0 12 12" fill="none">
|
||
<path d="M10 3L4.5 8.5L2 6" stroke="white" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 选中状态下的边框高亮 */}
|
||
{isSelectionMode && isSelected && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
border: '3px solid #10b981',
|
||
borderRadius: 4,
|
||
pointerEvents: 'none',
|
||
zIndex: 5,
|
||
}} />
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 下载文件
|
||
const handleDownload = (item: any) => {
|
||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.videoUrl || item.imageUrl}`;
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = item.title || item.id || 'download';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
// 预览文件
|
||
const handlePreview = (item: any) => {
|
||
setPreviewItem(item);
|
||
setPreviewVisible(true);
|
||
// 触发事件通知布局组件关闭浮动按钮
|
||
window.dispatchEvent(new Event('previewOpen'));
|
||
};
|
||
|
||
// 关闭预览并暂停视频
|
||
const handleClosePreview = () => {
|
||
// 方法1: 使用 ref
|
||
if (videoRef.current) {
|
||
videoRef.current.pause();
|
||
videoRef.current.currentTime = 0;
|
||
}
|
||
// 方法2: 直接通过 DOM 查询(备用)
|
||
const videoElements = document.querySelectorAll('video');
|
||
videoElements.forEach(video => {
|
||
video.pause();
|
||
video.currentTime = 0;
|
||
});
|
||
setPreviewVisible(false);
|
||
};
|
||
|
||
// 获取item的资源ID(优先使用generatedResourceId,否则使用id)
|
||
const getItemResourceId = (item: any): string => {
|
||
return item.generatedResourceId || item.id;
|
||
};
|
||
|
||
// 判断item是否有generatedResourceId
|
||
const hasGeneratedResourceId = (item: any): boolean => {
|
||
return Boolean(item.generatedResourceId);
|
||
};
|
||
|
||
// 多选相关函数
|
||
const handleToggleSelect = (itemId: string) => {
|
||
setSelectedItems(prev => {
|
||
const newSet = new Set(prev);
|
||
if (newSet.has(itemId)) {
|
||
newSet.delete(itemId);
|
||
} else {
|
||
newSet.add(itemId);
|
||
}
|
||
return newSet;
|
||
});
|
||
};
|
||
|
||
const handleSelectAll = () => {
|
||
const allItemIds = recordlist.flatMap((group: any) =>
|
||
group.items.map((item: any) => getItemResourceId(item))
|
||
);
|
||
if (selectedItems.size === allItemIds.length) {
|
||
setSelectedItems(new Set());
|
||
} else {
|
||
setSelectedItems(new Set(allItemIds));
|
||
}
|
||
};
|
||
|
||
const handleDownloadSelected = async () => {
|
||
if (selectedItems.size === 0) {
|
||
message.warning('请先选择要下载的媒体');
|
||
return;
|
||
}
|
||
|
||
// 检查下载数量限制
|
||
if (selectedItems.size > 10) {
|
||
message.warning('最多只能单次下载10个文件');
|
||
return;
|
||
}
|
||
|
||
const selectedContent: any[] = [];
|
||
recordlist.forEach((group: any) => {
|
||
group.items.forEach((item: any) => {
|
||
if (selectedItems.has(getItemResourceId(item))) {
|
||
selectedContent.push(item);
|
||
}
|
||
});
|
||
});
|
||
|
||
const zip = new JSZip();
|
||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||
const folder = zip.folder('downloads');
|
||
let hasError = false;
|
||
let successCount = 0;
|
||
|
||
message.loading({ content: '下载中,请稍候...', key: 'downloadProgress' });
|
||
|
||
for (const item of selectedContent) {
|
||
const url = `${baseUrl}${item.videoUrl || item.imageUrl}`;
|
||
const filename = ((item.videoUrl || item.imageUrl).split('/').pop() || `file_${Date.now()}`).split('?')[0];
|
||
try {
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error('Network response was not ok');
|
||
const blob = await response.blob();
|
||
folder?.file(filename, blob);
|
||
successCount++;
|
||
} catch (error) {
|
||
console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
|
||
hasError = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (hasError) {
|
||
// Fallback: 逐个打开文件下载(不受 CORS 限制)
|
||
message.destroy('downloadProgress');
|
||
message.info('由于跨域限制,将逐个下载文件');
|
||
selectedContent.forEach((item, index) => {
|
||
setTimeout(() => {
|
||
const url = `${baseUrl}${item.videoUrl || item.imageUrl}&download=1`;
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = '';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}, index * 500);
|
||
});
|
||
return;
|
||
}
|
||
|
||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(zipBlob);
|
||
link.download = `downloads_${Date.now()}.zip`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
|
||
message.destroy('downloadProgress');
|
||
message.success(`下载完成,共 ${successCount} 个文件`);
|
||
};
|
||
|
||
|
||
const handleBatchUploadSelected = () => {
|
||
if (selectedItems.size === 0) {
|
||
message.warning('请先选择要上传的媒体');
|
||
return;
|
||
}
|
||
setAccountIdList([]);
|
||
setAccountIdInput('');
|
||
setUploadConfigModalVisible(true);
|
||
};
|
||
|
||
const loadOAuthList = async (page: number, pageSize: number) => {
|
||
setOauthLoading(true);
|
||
try {
|
||
const res = await getOAuthList({ page, page_size: pageSize });
|
||
const data = res?.data || res;
|
||
setOauthList(data || []);
|
||
setOauthTotal(res.pagination.total || 0);
|
||
} catch (error) {
|
||
console.error('加载授权列表失败:', error);
|
||
setOauthList([]);
|
||
setOauthTotal(0);
|
||
} finally {
|
||
setOauthLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadUploadHistory = async () => {
|
||
setUploadHistoryLoading(true);
|
||
try {
|
||
const res = await getUploadHistory({
|
||
status: uploadHistoryStatus || undefined,
|
||
page: uploadHistoryPage,
|
||
pageSize: uploadHistoryPageSize,
|
||
});
|
||
const data = res?.data || res;
|
||
setUploadHistoryList(data || []);
|
||
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
|
||
} catch (error) {
|
||
console.error('加载上传历史失败:', error);
|
||
setUploadHistoryList([]);
|
||
setUploadHistoryTotal(0);
|
||
} finally {
|
||
setUploadHistoryLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleOpenUploadHistory = () => {
|
||
setUploadHistoryModalVisible(true);
|
||
setUploadHistoryPage(1);
|
||
setUploadHistoryStatus('');
|
||
loadUploadHistory();
|
||
};
|
||
|
||
const handleUploadHistorySearch = () => {
|
||
setUploadHistoryPage(1);
|
||
loadUploadHistory();
|
||
};
|
||
|
||
const handleUploadHistoryPageChange = (page: number, pageSize: number) => {
|
||
setUploadHistoryPage(page);
|
||
setUploadHistoryPageSize(pageSize);
|
||
loadUploadHistory();
|
||
};
|
||
|
||
// 批量上传素材
|
||
const handleStartBatchUpload = async () => {
|
||
if (!selectedOauthItems) {
|
||
message.warning('请先选择授权账户');
|
||
return;
|
||
}
|
||
if (selectedItems.size === 0) {
|
||
message.warning('请先选择要上传的媒体');
|
||
return;
|
||
}
|
||
setUploading(true);
|
||
try {
|
||
const tasks: {
|
||
advertiser_ids: string[];
|
||
resource_ids: string[];
|
||
oauth_id: string;
|
||
source_model: string;
|
||
}[] = [];
|
||
const advertiserIds = accountIdList.map(account => account.accountId);
|
||
// 创建itemId到item对象的映射
|
||
const itemMap = new Map<string, any>();
|
||
recordlist.forEach((group: any) => {
|
||
group.items.forEach((item: any) => {
|
||
const resourceId = getItemResourceId(item);
|
||
itemMap.set(resourceId, item);
|
||
});
|
||
});
|
||
|
||
for (const itemId of selectedItems) {
|
||
const item = itemMap.get(itemId);
|
||
// 根据item是否有generatedResourceId来决定source_model
|
||
let sourceModel: string;
|
||
if (item && hasGeneratedResourceId(item)) {
|
||
sourceModel = 'generated_resources';
|
||
} else {
|
||
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
|
||
}
|
||
|
||
tasks.push({
|
||
advertiser_ids: advertiserIds,
|
||
resource_ids: [itemId],
|
||
oauth_id: selectedOauthItems.value,
|
||
source_model: sourceModel,
|
||
});
|
||
}
|
||
await asyncBatchUploadMaterial({ tasks });
|
||
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
// 关闭弹窗并清理状态
|
||
setUploadConfigModalVisible(false);
|
||
setAccountIdList([]);
|
||
setAccountIdInput('');
|
||
setSelectedOauthItems(undefined);
|
||
setMaterialFileNames(new Map());
|
||
setUnifiedFileName('');
|
||
} catch (error: any) {
|
||
console.error('批量上传失败:', error);
|
||
message.error(error.message || '批量上传失败');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
// 更新文件名函数
|
||
const handleUpdateFileName = async (sourceId: string, newFileName: string) => {
|
||
if (!newFileName.trim()) return;
|
||
try {
|
||
const response = await updateFilename({
|
||
filenames: [{ source_id: sourceId, file_name: newFileName }],
|
||
});
|
||
// 更新 recordlist 中的文件名,使用 API 返回的 new_file_name
|
||
const result = response?.results?.find((r: any) => r.source_id === sourceId);
|
||
const actualFileName = result?.new_file_name || newFileName;
|
||
setRecordList(prevList => {
|
||
return prevList.map(group => ({
|
||
...group,
|
||
items: group.items.map((item: any) => {
|
||
const resourceId = getItemResourceId(item);
|
||
if (resourceId === sourceId) {
|
||
return { ...item, fileName: actualFileName };
|
||
}
|
||
return item;
|
||
}),
|
||
}));
|
||
});
|
||
message.success('文件名更新成功');
|
||
} catch (error: any) {
|
||
console.error('文件名更新失败:', error);
|
||
message.error(error.message || '文件名更新失败');
|
||
}
|
||
};
|
||
|
||
// 批量更新文件名函数
|
||
const handleBatchUpdateFileName = async (sourceIds: string[], newFileName: string) => {
|
||
if (!newFileName.trim() || sourceIds.length === 0) return;
|
||
try {
|
||
const filenames = sourceIds.map(sourceId => ({
|
||
source_id: sourceId,
|
||
file_name: newFileName,
|
||
}));
|
||
const response = await updateFilename({ filenames });
|
||
// 批量更新 recordlist 中的文件名,使用 API 返回的 new_file_name
|
||
const resultsMap = new Map<string, string>();
|
||
response?.results?.forEach((r: any) => {
|
||
if (r.success && r.new_file_name) {
|
||
resultsMap.set(r.source_id, r.new_file_name);
|
||
}
|
||
});
|
||
setRecordList(prevList => {
|
||
const sourceIdSet = new Set(sourceIds);
|
||
return prevList.map(group => ({
|
||
...group,
|
||
items: group.items.map((item: any) => {
|
||
const resourceId = getItemResourceId(item);
|
||
if (sourceIdSet.has(resourceId)) {
|
||
const actualFileName = resultsMap.get(resourceId) || newFileName;
|
||
return { ...item, fileName: actualFileName };
|
||
}
|
||
return item;
|
||
}),
|
||
}));
|
||
});
|
||
const successCount = response?.success_count || 0;
|
||
message.success(`已更新 ${successCount} 个文件名`);
|
||
} catch (error: any) {
|
||
console.error('文件名更新失败:', error);
|
||
message.error(error.message || '文件名更新失败');
|
||
}
|
||
};
|
||
|
||
// 日期选择器变化处理函数
|
||
const handleDateChange = (dateString: string) => {
|
||
setSelectedDate(dateString);
|
||
};
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
let parameters = '';
|
||
if (filterType === 'project') {
|
||
parameters = `?gen_type=${filterMedia}&history_source=generation_record&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||
} else {
|
||
parameters = `?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||
}
|
||
|
||
if (selectedDate) {
|
||
let parameters = ``;
|
||
|
||
if (filterType === 'project') {
|
||
parameters = `${selectedDate}?gen_type=${filterMedia}&history_source=generation_record&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||
} else {
|
||
parameters = `${selectedDate}?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||
}
|
||
gethistoryItems(parameters).then((res: any) => {
|
||
const data = Array.isArray(res) ? res : (res?.items || []);
|
||
let recordList = [{
|
||
generatedDate: res.generatedDate,
|
||
items: data,
|
||
total: res.total,
|
||
page: res.page,
|
||
}];
|
||
if (recordList && recordList[0].items.length > 0) {
|
||
setRecordList(recordList);
|
||
} else {
|
||
setRecordList([]);
|
||
}
|
||
}).catch((err) => {
|
||
}).finally(() => {
|
||
setLoading(false);
|
||
});
|
||
|
||
try {
|
||
} catch (err) {
|
||
} finally {
|
||
|
||
}
|
||
} else {
|
||
gethistory(parameters).then((res: any) => {
|
||
const data = Array.isArray(res) ? res : (res?.groups || []);
|
||
data.forEach(group => {
|
||
group.page = 1;
|
||
});
|
||
// 如果是第一页,替换数据;否则追加数据
|
||
if (Pagebreak.page === 1) {
|
||
setRecordList(data);
|
||
} else {
|
||
setRecordList(prev => [...prev, ...data]);
|
||
}
|
||
setTotalnumber(res?.totalDays || 0);
|
||
}).catch((err) => {
|
||
if (Pagebreak.page === 1) {
|
||
setRecordList([]);
|
||
}
|
||
}).finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}
|
||
|
||
|
||
}, [filterType, filterMedia, Pagebreak.page, selectedDate]);
|
||
|
||
// 加载更多
|
||
const handleLoadMore = () => {
|
||
if (loading) return;
|
||
setPagebreak(prev => ({
|
||
...prev,
|
||
page: prev.page + 1
|
||
}));
|
||
};
|
||
|
||
// 分组加载更多
|
||
const handleGroupLoadMore = async (time: string, date: string, page: number) => {
|
||
if (loadingGroups.has(date)) return;
|
||
setLoadingGroups(prev => new Set([...prev, date]));
|
||
|
||
const addpage = page + 1;
|
||
|
||
let parameters = ``;
|
||
|
||
if (filterType === 'project') {
|
||
parameters = `${time}?gen_type=${filterMedia}&history_source=generation_record&page=${addpage}&page_size=${Pagebreak.pageSize}`;
|
||
} else {
|
||
parameters = `${time}?gen_type=${filterMedia}&page=${addpage}&page_size=${Pagebreak.pageSize}`;
|
||
}
|
||
|
||
try {
|
||
const res: any = await gethistoryItems(parameters);
|
||
|
||
// gethistoryItems 返回数组,直接使用
|
||
const newItems: any[] = res.items || [];
|
||
|
||
if (newItems && newItems.length > 0) {
|
||
setRecordList(prev => prev.map(group => {
|
||
if (group.generatedDate === time) {
|
||
return {
|
||
...group,
|
||
items: [...group.items, ...newItems],
|
||
page: addpage
|
||
};
|
||
}
|
||
return group;
|
||
}));
|
||
}
|
||
} catch (err) {
|
||
} finally {
|
||
setLoadingGroups(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(date);
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
// 当筛选条件改变时,重置页码
|
||
useEffect(() => {
|
||
setPagebreak(prev => ({
|
||
...prev,
|
||
page: 1
|
||
}));
|
||
}, [filterType, filterMedia]);
|
||
|
||
return (
|
||
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
|
||
{/* 操作栏:筛选 + 上传按钮 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 16,
|
||
padding: '12px 20px',
|
||
borderRadius: 12,
|
||
background: '#fff',
|
||
border: '1px solid #f0f0f5',
|
||
justifyContent: 'space-between',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<FilterOutlined style={{ color: '#94a3b8', fontSize: 14 }} />
|
||
<Space>
|
||
<Button
|
||
type={filterType === 'project' ? 'primary' : 'default'}
|
||
onClick={() => {
|
||
setFilterType('project');
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: filterType === 'project'
|
||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||
: '#f8f9fc',
|
||
border: filterType === 'project' ? 'none' : '1px solid #e2e8f0',
|
||
color: filterType === 'project' ? '#fff' : '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
icon={<FolderOpenOutlined />}
|
||
>
|
||
项目记录
|
||
</Button>
|
||
<Button
|
||
type={filterType === 'creation' ? 'primary' : 'default'}
|
||
onClick={() => {
|
||
setFilterType('creation');
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: filterType === 'creation'
|
||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||
: '#f8f9fc',
|
||
border: filterType === 'creation' ? 'none' : '1px solid #e2e8f0',
|
||
color: filterType === 'creation' ? '#fff' : '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
icon={<FileTextOutlined />}
|
||
>
|
||
创作记录
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
{/* 多选模式按钮 */}
|
||
{isSelectionMode ? (
|
||
<Space>
|
||
<Button
|
||
onClick={handleSelectAll}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: '#f8f9fc',
|
||
border: '1px solid #e2e8f0',
|
||
color: '#222222ff',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) ? '取消全选' : '全选'}
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: '#f8f9fc',
|
||
border: '1px solid #e2e8f0',
|
||
color: '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
取消选择
|
||
</Button>
|
||
<Button
|
||
onClick={() => handleDownloadSelected()}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||
color: '#fff',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
下载 ({selectedItems.size})
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleBatchUploadSelected}
|
||
loading={uploading}
|
||
disabled={uploading || selectedItems.size === 0}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||
color: '#fff',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{uploading ? '上传中...' : `推送至账户 (${selectedItems.size})`}
|
||
</Button>
|
||
</Space>
|
||
) : (
|
||
<Button
|
||
type="primary"
|
||
icon={<UploadOutlined />}
|
||
onClick={() => setIsSelectionMode(true)}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||
border: 'none',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
批量操作
|
||
</Button>
|
||
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Second row filter: 视频 / 图片 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 24,
|
||
padding: '12px 20px',
|
||
borderRadius: 12,
|
||
background: '#fff',
|
||
border: '1px solid #f0f0f5',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>媒体类型:</Typography.Text>
|
||
|
||
<Space>
|
||
<Button
|
||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||
onClick={() => {
|
||
setFilterMedia('video');
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: filterMedia === 'video'
|
||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||
: '#f8f9fc',
|
||
border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0',
|
||
color: filterMedia === 'video' ? '#fff' : '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
icon={<VideoCameraOutlined />}
|
||
>
|
||
视频
|
||
</Button>
|
||
<Button
|
||
type={filterMedia === 'image' ? 'primary' : 'default'}
|
||
onClick={() => {
|
||
setFilterMedia('image');
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: filterMedia === 'image'
|
||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||
: '#f8f9fc',
|
||
border: filterMedia === 'image' ? 'none' : '1px solid #e2e8f0',
|
||
color: filterMedia === 'image' ? '#fff' : '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
icon={<PictureOutlined />}
|
||
>
|
||
图片
|
||
</Button>
|
||
<DatePicker
|
||
picker="date"
|
||
value={selectedDate ? dayjs(selectedDate) : undefined}
|
||
onChange={(date, dateString) => handleDateChange(dateString || '')}
|
||
format="YYYY-MM-DD"
|
||
style={{ width: 160, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||
placeholder="选择日期"
|
||
/>
|
||
{selectedDate && (
|
||
<Button
|
||
type="text"
|
||
onClick={() => handleDateChange('')}
|
||
style={{ color: '#94a3b8', fontSize: 12 }}
|
||
>
|
||
清除
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
</div>
|
||
<Button
|
||
icon={<ClockCircleOutlined />}
|
||
onClick={handleOpenUploadHistory}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||
border: 'none',
|
||
color: '#ffffff',
|
||
fontWeight: 600,
|
||
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
|
||
transition: 'all 0.3s ease',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.6)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)';
|
||
}}
|
||
>
|
||
查询上传任务历史
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Content area */}
|
||
{recordlist.length === 0 ? (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="暂无生成记录"
|
||
style={{ padding: '60px 0' }}
|
||
/>
|
||
) : (
|
||
<div style={{ padding: '0 4px' }}>
|
||
{recordlist.map((group: any, index: number) => (
|
||
<div key={index} style={{ marginBottom: 32 }}>
|
||
{/* Date label */}
|
||
<div style={{
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: '#64748b',
|
||
marginBottom: 12,
|
||
paddingLeft: 8,
|
||
}}>
|
||
{group.generatedDate}
|
||
</div>
|
||
{/* Media grid */}
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
}}>
|
||
{group.items.map((item: any) => (
|
||
<LazyMedia
|
||
key={getItemResourceId(item)}
|
||
item={item}
|
||
mediaType={filterMedia}
|
||
onClick={() => isSelectionMode ? handleToggleSelect(getItemResourceId(item)) : handlePreview(item)}
|
||
isSelected={selectedItems.has(getItemResourceId(item))}
|
||
onToggleSelect={handleToggleSelect}
|
||
isSelectionMode={isSelectionMode}
|
||
/>
|
||
))}
|
||
</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.generatedDate)}
|
||
disabled={loadingGroups.has(group.generatedDate)}
|
||
size="small"
|
||
style={{
|
||
borderRadius: 6,
|
||
background: 'transparent',
|
||
border: '1px dashed #cbd5e1',
|
||
color: '#64748b',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{loadingGroups.has(group.date) ? '加载中...' : `查看全部 (${group.total})`}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{/* 加载更多按钮 */}
|
||
{recordlist.length > 0 && Totalnumber > recordlist.length && (
|
||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||
<Button
|
||
onClick={handleLoadMore}
|
||
loading={loading}
|
||
disabled={loading}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: '#f8f9fc',
|
||
border: '1px solid #e2e8f0',
|
||
color: '#64748b',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{loading ? '加载中...' : '加载更多'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 上传配置弹窗 */}
|
||
<Modal
|
||
title="批量上传配置"
|
||
open={uploadConfigModalVisible}
|
||
onCancel={() => {
|
||
setUploadConfigModalVisible(false);
|
||
setAccountIdList([]);
|
||
setAccountIdInput('');
|
||
setSelectedOauthItems(undefined);
|
||
setMaterialFileNames(new Map());
|
||
setUnifiedFileName('');
|
||
}}
|
||
footer={null}
|
||
width={900}
|
||
mask={{ closable: false }}
|
||
>
|
||
<div style={{ padding: '16px 0' }}>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||
选中素材 ({selectedItems.size}个)
|
||
</Typography.Text>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: 8,
|
||
marginBottom: 12,
|
||
alignItems: 'center',
|
||
}}>
|
||
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}>统一修改名称:</Typography.Text>
|
||
<Input
|
||
value={unifiedFileName}
|
||
onChange={(e) => setUnifiedFileName(e.target.value)}
|
||
placeholder="输入名称后点击应用"
|
||
style={{ flex: 1, borderRadius: 8 }}
|
||
size="small"
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
onClick={() => {
|
||
if (unifiedFileName.trim() && selectedItems.size > 0) {
|
||
handleBatchUpdateFileName(Array.from(selectedItems), unifiedFileName);
|
||
}
|
||
}}
|
||
disabled={!unifiedFileName.trim() || selectedItems.size === 0}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
应用
|
||
</Button>
|
||
</div>
|
||
<div style={{
|
||
maxHeight: 300,
|
||
overflow: 'auto',
|
||
border: '1px solid #f0f0f0',
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
}}>
|
||
{(() => {
|
||
const itemMap = new Map<string, any>();
|
||
recordlist.forEach((group: any) => {
|
||
group.items.forEach((item: any) => {
|
||
const resourceId = getItemResourceId(item);
|
||
itemMap.set(resourceId, item);
|
||
});
|
||
});
|
||
return Array.from(selectedItems).map((itemId) => {
|
||
const item = itemMap.get(itemId);
|
||
return (
|
||
<div
|
||
key={itemId}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '8px 0',
|
||
borderBottom: '1px solid #f5f5f5',
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 60,
|
||
height: 40,
|
||
borderRadius: 4,
|
||
backgroundColor: '#f5f5f5',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
overflow: 'hidden',
|
||
flexShrink: 0,
|
||
}}>
|
||
{(() => {
|
||
const coverField = filterMedia === 'video' ? item?.videoCoverUrl : item?.imageUrl;
|
||
if (!coverField) {
|
||
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>预览</Typography.Text>;
|
||
}
|
||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||
const cleanPath = coverField.startsWith('/') ? coverField.slice(1) : coverField;
|
||
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||
const coverUrl = `${cleanBase}/static/${cleanPath}&w=300&q=50`;
|
||
return (
|
||
<img
|
||
src={coverUrl}
|
||
alt=""
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<Typography.Text style={{ fontSize: 12, color: '#1e293b' }}>
|
||
{item?.fileName || `素材 ${item.id}`}
|
||
</Typography.Text>
|
||
</div>
|
||
<Input
|
||
value={materialFileNames.get(itemId) || item?.fileName || ''}
|
||
onChange={(e) => {
|
||
const newName = e.target.value;
|
||
const newNames = new Map(materialFileNames);
|
||
newNames.set(itemId, newName);
|
||
setMaterialFileNames(newNames);
|
||
// 防抖调用API
|
||
if (updateFilenameDebounceRef.current) {
|
||
clearTimeout(updateFilenameDebounceRef.current);
|
||
}
|
||
updateFilenameDebounceRef.current = setTimeout(() => {
|
||
handleUpdateFileName(itemId, newName);
|
||
}, 800);
|
||
}}
|
||
placeholder="输入新名称"
|
||
style={{ width: 200, borderRadius: 4 }}
|
||
size="small"
|
||
/>
|
||
</div>
|
||
);
|
||
});
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||
选择授权账户
|
||
</Typography.Text>
|
||
<Select
|
||
value={selectedOauthItems}
|
||
onChange={(value) => {
|
||
setSelectedOauthItems(value as { value: string; label: string } | undefined);
|
||
}}
|
||
placeholder="点击选择授权账户"
|
||
style={{ width: '100%', marginBottom: 16, borderRadius: 8 }}
|
||
popupRender={() => (
|
||
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
|
||
<Table
|
||
dataSource={oauthList}
|
||
columns={[
|
||
{
|
||
title: 'ID',
|
||
dataIndex: 'id',
|
||
key: 'id',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '授权账户名称',
|
||
dataIndex: 'accountName',
|
||
key: 'accountName',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '授权应用ID',
|
||
dataIndex: 'appid',
|
||
key: 'appid',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '授权用户ID',
|
||
dataIndex: 'accountUserid',
|
||
key: 'accountUserid',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '授权账户角色',
|
||
dataIndex: 'accountRole',
|
||
key: 'accountRole',
|
||
width: 200,
|
||
render: (role: string) => {
|
||
const roleMap: Record<string, string> = {
|
||
ADVERTISER: '客户',
|
||
CUSTOMER_ADMIN: '普通版工作台-管理员',
|
||
CUSTOMER_OPERATOR: '普通版工作台-协作者',
|
||
AGENT: '代理商',
|
||
CHILD_AGENT: '二级代理商',
|
||
PLATFORM_ROLE_STAR: '星图账户',
|
||
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
|
||
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
|
||
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
|
||
PLATFORM_ROLE_AWEME: '抖音号',
|
||
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
|
||
PLATFORM_ROLE_STAR_ISV: '星图服务商',
|
||
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
|
||
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
|
||
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
|
||
PLATFORM_ROLE_LIFE: '抖音来客账户',
|
||
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
|
||
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
|
||
};
|
||
return roleMap[role] || role;
|
||
},
|
||
},
|
||
{
|
||
title: '授权账户用户名',
|
||
dataIndex: 'accountUsername',
|
||
key: 'accountUsername',
|
||
width: 120,
|
||
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
|
||
},
|
||
]}
|
||
loading={oauthLoading}
|
||
pagination={{
|
||
current: oauthPage,
|
||
pageSize: oauthPageSize,
|
||
total: oauthTotal,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条记录`,
|
||
onChange: (page, size) => {
|
||
setOauthPage(page);
|
||
setOauthPageSize(size);
|
||
loadOAuthList(page, size);
|
||
},
|
||
}}
|
||
rowKey="id"
|
||
size="small"
|
||
onRow={(record) => ({
|
||
onClick: () => {
|
||
const id = String(record.id);
|
||
setSelectedOauthItems({ value: id, label: String(record.accountUserid) });
|
||
},
|
||
style: {
|
||
cursor: 'pointer',
|
||
backgroundColor: selectedOauthItems?.value === String(record.id) ? '#e6f7ff' : undefined,
|
||
},
|
||
})}
|
||
/>
|
||
</div>
|
||
)}
|
||
open={oauthSelectOpen}
|
||
onOpenChange={(open) => {
|
||
setOauthSelectOpen(open);
|
||
if (open) {
|
||
loadOAuthList(1, oauthPageSize);
|
||
}
|
||
}}
|
||
labelInValue
|
||
fieldNames={{ label: 'accountUserid', value: 'id' }}
|
||
/>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block', marginTop: 16 }}>
|
||
粘贴账户ID(每行一个或用逗号分隔)
|
||
</Typography.Text>
|
||
<Input.TextArea
|
||
value={accountIdInput}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setAccountIdInput(value);
|
||
const ids = value.split(/[\n,]/)
|
||
.map(line => line.trim())
|
||
.filter(line => line.length > 0);
|
||
const uniqueIds = [...new Set(ids)];
|
||
const textAccounts = uniqueIds.map(id => ({ accountId: id }));
|
||
const seen = new Set<string>();
|
||
const finalAccounts = textAccounts.filter(a => {
|
||
if (seen.has(a.accountId)) return false;
|
||
seen.add(a.accountId);
|
||
return true;
|
||
});
|
||
setAccountIdList(finalAccounts);
|
||
}}
|
||
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
|
||
10001,10002,10003
|
||
10004"
|
||
rows={4}
|
||
style={{ borderRadius: 8, marginBottom: 16 }}
|
||
/>
|
||
|
||
{/* 操作按钮 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: 12,
|
||
justifyContent: 'flex-end',
|
||
}}>
|
||
<Button
|
||
onClick={() => {
|
||
setUploadConfigModalVisible(false);
|
||
setAccountIdList([]);
|
||
setAccountIdInput('');
|
||
setSelectedOauthItems(undefined);
|
||
setMaterialFileNames(new Map());
|
||
setUnifiedFileName('');
|
||
}}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleStartBatchUpload}
|
||
loading={uploading}
|
||
disabled={uploading || accountIdList.length === 0}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
{uploading ? '上传中...' : '开始上传'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 上传任务历史弹窗 */}
|
||
<Modal
|
||
title="上传任务历史"
|
||
open={uploadHistoryModalVisible}
|
||
onCancel={() => setUploadHistoryModalVisible(false)}
|
||
footer={null}
|
||
width={800}
|
||
style={{ borderRadius: 8 }}
|
||
mask={{ closable: false }}
|
||
>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Select
|
||
value={uploadHistoryStatus}
|
||
onChange={(value) => setUploadHistoryStatus(value)}
|
||
placeholder="选择状态"
|
||
style={{ width: 200, marginRight: 12 }}
|
||
options={[
|
||
{ value: '1', label: '待上传' },
|
||
{ value: '2', label: '上传中' },
|
||
{ value: '3', label: '上传成功' },
|
||
{ value: '4', label: '上传失败' },
|
||
]}
|
||
allowClear
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleUploadHistorySearch}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
查询
|
||
</Button>
|
||
</div>
|
||
<Table
|
||
dataSource={uploadHistoryList}
|
||
columns={[
|
||
{
|
||
title: '素材名称',
|
||
dataIndex: 'fileName',
|
||
key: 'fileName',
|
||
width: 200,
|
||
},
|
||
{
|
||
title: '账户ID',
|
||
dataIndex: 'advertiserId',
|
||
key: 'advertiserId',
|
||
width: 180,
|
||
},
|
||
// {
|
||
// title: '状态',
|
||
// dataIndex: 'status',
|
||
// key: 'status',
|
||
// width: 100,
|
||
// render: (status: number, record: any) => {
|
||
// const statusColorMap: Record<number, string> = {
|
||
// 1: '#f59e0b',
|
||
// 2: '#6366f1',
|
||
// 3: '#10b981',
|
||
// 4: '#ef4444',
|
||
// };
|
||
// return (
|
||
// <Tag color={statusColorMap[status] || '#64748b'} style={{ borderRadius: 4 }}>
|
||
// {record.status_text || status}
|
||
// </Tag>
|
||
// );
|
||
// },
|
||
// },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
width: 100,
|
||
render: (status: string) => {
|
||
const statusMap: Record<string, string> = {
|
||
'1': '待上传',
|
||
'2': '上传中',
|
||
'3': '上传成功',
|
||
'4': '上传失败',
|
||
};
|
||
const statusColorMap: Record<string, string> = {
|
||
'1': '#f59e0b',
|
||
'2': '#6366f1',
|
||
'3': '#10b981',
|
||
'4': '#ef4444',
|
||
};
|
||
return (
|
||
<Tag color={statusColorMap[status] || '#64748b'} style={{ borderRadius: 4 }}>
|
||
{statusMap[status] || status}
|
||
</Tag>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '备注',
|
||
dataIndex: 'note',
|
||
key: 'note',
|
||
width: 250,
|
||
ellipsis: true,
|
||
render: (note: string) => (
|
||
<span style={{ color: '#94a3b8' }}>
|
||
{note || '-'}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'created_at',
|
||
key: 'created_at',
|
||
width: 180,
|
||
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||
},
|
||
|
||
]}
|
||
loading={uploadHistoryLoading}
|
||
scroll={{ x: 'max-content' }}
|
||
pagination={{
|
||
current: uploadHistoryPage,
|
||
pageSize: uploadHistoryPageSize,
|
||
total: uploadHistoryTotal,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条记录`,
|
||
onChange: handleUploadHistoryPageChange,
|
||
}}
|
||
rowKey={(record, index) => record.task_id || record.resource_id || index}
|
||
size="small"
|
||
/>
|
||
</Modal>
|
||
|
||
{/* 预览弹窗 */}
|
||
{previewVisible && previewItem && (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'rgba(0,0,0,0.85)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1000,
|
||
padding: 16,
|
||
boxSizing: 'border-box',
|
||
overflow: 'auto',
|
||
}}
|
||
onClick={handleClosePreview}
|
||
>
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 16,
|
||
padding: 0,
|
||
width: '100%',
|
||
maxWidth: '1200px',
|
||
maxHeight: '95vh',
|
||
minHeight: '300px',
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 头部 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '12px 16px',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
flexShrink: 0,
|
||
}}>
|
||
<Typography.Title level={5} style={{ margin: 0, color: '#1a1a2e', fontSize: 16 }}>
|
||
{previewItem.title || '预览'}
|
||
</Typography.Title>
|
||
<Button
|
||
icon={<XOutlined />}
|
||
onClick={handleClosePreview}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#94a3b8',
|
||
fontSize: 16,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 内容区域 */}
|
||
<div style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
height: '500px',
|
||
gap: 20,
|
||
padding: 20,
|
||
overflow: 'auto',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
}}>
|
||
{/* 媒体预览 */}
|
||
<div style={{
|
||
flex: 1,
|
||
minWidth: '280px',
|
||
maxWidth: '800px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
minHeight: '200px',
|
||
}}>
|
||
{/* 检查媒体是否过期 */}
|
||
{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}`}
|
||
controls
|
||
autoPlay
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '55vh',
|
||
borderRadius: 8,
|
||
objectFit: 'contain',
|
||
}}
|
||
/>
|
||
) : (
|
||
<img
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewItem.imageUrl}&w=300&q=50`}
|
||
alt="预览"
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '55vh',
|
||
objectFit: 'contain',
|
||
borderRadius: 8
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 参数信息 */}
|
||
<div style={{
|
||
width: '100%',
|
||
minWidth: '280px',
|
||
maxWidth: '320px',
|
||
background: '#f8fafc',
|
||
borderRadius: 12,
|
||
padding: 20,
|
||
maxHeight: '55vh',
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden',
|
||
}}>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', display: 'block', marginBottom: 16 }}>
|
||
文件信息
|
||
</Typography.Text>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{/* ID */}
|
||
{/* <div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>ID</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.id || '-'}
|
||
</span>
|
||
</div> */}
|
||
|
||
{/* 类型 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>类型</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{filterMedia === 'video' ? '视频' : '图片'}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13, flexShrink: 0, width: 40 }}>请求</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500, flex: 1, wordBreak: 'break-all' }}>
|
||
{previewItem.originalPrompt}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 分辨率 */}
|
||
{filterMedia === 'image' && (
|
||
<>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>比例</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imageProportion}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>分辨率</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imageSize}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>尺寸</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imagePx}
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
{filterMedia === 'video' && (
|
||
<>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>比例</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.aspectRatio}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>分辨率</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.resolution}
|
||
</span>
|
||
</div>
|
||
{/* <div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>尺寸</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imagePx}
|
||
</span>
|
||
</div> */}
|
||
</>
|
||
)}
|
||
|
||
{/* 时长(视频) */}
|
||
{filterMedia === 'video' && (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>时长</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{`${previewItem.duration}秒` || '-'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 文件大小 */}
|
||
{/* <div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>文件大小</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.size ? formatFileSize(previewItem.size) : '-'}
|
||
</span>
|
||
</div> */}
|
||
|
||
{/* 创建时间 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>创建时间</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{formatDateTime(previewItem.createdAt || previewItem.generatedDate)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 生成引擎 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>生成引擎</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.engine || previewItem.engineName || '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分隔线 */}
|
||
<div style={{ borderTop: '1px dashed #e2e8f0', margin: '16px 0' }} />
|
||
|
||
|
||
{previewItem.mediaReferences && previewItem.mediaReferences.length > 0 && (
|
||
<>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', display: 'block', marginBottom: 12 }}>
|
||
依靠附件
|
||
</Typography.Text>
|
||
{previewItem.mediaReferences.map((item, index) => (
|
||
<div
|
||
key={item.url}
|
||
onClick={() => {
|
||
// 暂停视频
|
||
if (videoRef.current) {
|
||
videoRef.current.pause();
|
||
}
|
||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.url}`;
|
||
window.open(url, '_blank');
|
||
}}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: 8,
|
||
borderRadius: 6,
|
||
cursor: 'pointer',
|
||
backgroundColor: '#f1f5f9',
|
||
marginBottom: 4,
|
||
transition: 'background-color 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
(e.currentTarget as HTMLElement).style.backgroundColor = '#e2e8f0';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
(e.currentTarget as HTMLElement).style.backgroundColor = '#f1f5f9';
|
||
}}
|
||
>
|
||
{item.type === 'image' ? (
|
||
<PictureOutlined style={{ color: '#3b82f6', fontSize: 14 }} />
|
||
) : (
|
||
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 14 }} />
|
||
)}
|
||
<span style={{ color: '#334155', fontSize: 13 }}>
|
||
{item.name || `媒体${index + 1}`}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{/* 操作按钮 */}
|
||
<div >
|
||
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
|
||
<Button
|
||
type="primary"
|
||
icon={<DownloadOutlined />}
|
||
onClick={() => {
|
||
// 暂停视频
|
||
if (videoRef.current) {
|
||
videoRef.current.pause();
|
||
}
|
||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}&download=1`;
|
||
window.open(url, '_blank');
|
||
}}
|
||
style={{ flex: 1, borderRadius: 8 }}
|
||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||
>
|
||
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? '资源已过期' : '下载'}
|
||
</Button>
|
||
<Button
|
||
onClick={handleClosePreview}
|
||
style={{ flex: 1, borderRadius: 8 }}
|
||
>
|
||
关闭
|
||
</Button>
|
||
</div>
|
||
{/* <div>
|
||
<Button
|
||
|
||
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
|
||
>
|
||
|
||
推送媒体后台
|
||
</Button>
|
||
</div> */}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 文件大小格式化
|
||
function formatFileSize(bytes: number): string {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
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 '-';
|
||
const date = new Date(dateString);
|
||
if (isNaN(date.getTime())) return '-';
|
||
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||
}
|
||
|
||
export default GeneratedRecord;
|