Files
video-gen/video-gen-app/src/pages/GeneratedRecord.tsx
T
2026-06-26 12:43:13 +08:00

2190 lines
105 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 [accountIdLists, setAccountIdLists] = useState<{
accountId: string;
}[][]>([[]]);
const [accountIdInputs, setAccountIdInputs] = useState<string[]>(['']);
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 [oauthSelectOpens, setOauthSelectOpens] = useState<boolean[]>([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;
}
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setUploadConfigModalVisible(true);
};
const handleSinglePushToMedia = () => {
if (!previewItem) return;
const resourceId = getItemResourceId(previewItem);
setSelectedItems(new Set([resourceId]));
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
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 () => {
const validOauthItems = selectedOauthItems.filter(item => item !== undefined);
if (validOauthItems.length === 0) {
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 resourceIds = Array.from(selectedItems);
for (let i = 0; i < validOauthItems.length; i++) {
const oauthItem = validOauthItems[i];
const advertiserIds = accountIdLists[i]?.map(account => account.accountId) || [];
if (advertiserIds.length === 0) {
message.warning(`第 ${i + 1} 组授权账户未设置账户ID,已跳过`);
continue;
}
tasks.push({
advertiser_ids: advertiserIds,
resource_ids: resourceIds,
oauth_id: oauthItem.value,
source_model: filterType === 'project' ? 'generation_records' : 'chat_generation_tasks',
});
}
await asyncBatchUploadMaterial({ tasks });
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
setIsSelectionMode(false);
setSelectedItems(new Set());
// 关闭弹窗并清理状态
setUploadConfigModalVisible(false);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
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.newFileName) {
resultsMap.set(r.sourceId, r.newFileName);
}
});
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;
}),
}));
});
console.log(response);
const successCount = response?.successCount || 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={selectedItems.size === 1 ? '上传配置' : '批量上传配置'}
open={uploadConfigModalVisible}
onCancel={() => {
setUploadConfigModalVisible(false);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
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.has(itemId) ? 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>
{selectedOauthItems.map((oauthItem, index) => (
<div key={index} style={{ marginBottom: 16, padding: 12, border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#475569' }}>
授权账户 {index + 1}
</Typography.Text>
{selectedOauthItems.length > 1 && (
<Button
type="text"
danger
onClick={() => {
const newOauthItems = [...selectedOauthItems];
const newAccountIdInputs = [...accountIdInputs];
const newAccountIdLists = [...accountIdLists];
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthItems.splice(index, 1);
newAccountIdInputs.splice(index, 1);
newAccountIdLists.splice(index, 1);
newOauthSelectOpens.splice(index, 1);
setSelectedOauthItems(newOauthItems);
setAccountIdInputs(newAccountIdInputs);
setAccountIdLists(newAccountIdLists);
setOauthSelectOpens(newOauthSelectOpens);
}}
>
删除
</Button>
)}
</div>
<Select
value={oauthItem}
onChange={(value) => {
const newOauthItems = [...selectedOauthItems];
newOauthItems[index] = value as { value: string; label: string } | undefined;
setSelectedOauthItems(newOauthItems);
}}
placeholder="点击选择授权账户"
style={{ width: '100%', marginBottom: 12, borderRadius: 8 }}
popupRender={() => (
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
<Table
dataSource={oauthList}
columns={[
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
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: 160,
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"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
const id = String(record.id);
const newOauthItems = [...selectedOauthItems];
newOauthItems[index] = { value: id, label: String(record.accountId)+'-'+(record.accountName || '-') };
setSelectedOauthItems(newOauthItems);
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthSelectOpens[index] = false;
setOauthSelectOpens(newOauthSelectOpens);
},
style: {
cursor: 'pointer',
backgroundColor: oauthItem?.value === String(record.id) ? '#e6f7ff' : undefined,
},
})}
/>
</div>
)}
open={oauthSelectOpens[index]}
onOpenChange={(open) => {
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthSelectOpens[index] = open;
setOauthSelectOpens(newOauthSelectOpens);
if (open) {
loadOAuthList(1, oauthPageSize);
}
}}
labelInValue
fieldNames={{ label: 'accountUserid', value: 'id' }}
/>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
粘贴账户ID(每行一个或用逗号分隔)
</Typography.Text>
<Input.TextArea
value={accountIdInputs[index]}
onChange={(e) => {
const value = e.target.value;
const newAccountIdInputs = [...accountIdInputs];
newAccountIdInputs[index] = value;
setAccountIdInputs(newAccountIdInputs);
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;
});
const newAccountIdLists = [...accountIdLists];
newAccountIdLists[index] = finalAccounts;
setAccountIdLists(newAccountIdLists);
}}
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
10001,10002,10003
10004"
rows={3}
style={{ borderRadius: 8 }}
/>
</div>
))}
<Button
type="dashed"
block
onClick={() => {
setSelectedOauthItems([...selectedOauthItems, undefined]);
setAccountIdInputs([...accountIdInputs, '']);
setAccountIdLists([...accountIdLists, []]);
setOauthSelectOpens([...oauthSelectOpens, false]);
}}
style={{ borderRadius: 8, marginBottom: 16 }}
>
+ 新增授权账户组
</Button>
{/* 操作按钮 */}
<div style={{
display: 'flex',
gap: 12,
justifyContent: 'flex-end',
}}>
<Button
onClick={() => {
setUploadConfigModalVisible(false);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
}}
style={{ borderRadius: 8 }}
>
取消
</Button>
<Button
type="primary"
onClick={handleStartBatchUpload}
loading={uploading}
disabled={uploading || accountIdLists.every(list => list.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
type="primary"
onClick={handleSinglePushToMedia}
style={{ width: '100%', borderRadius: 8, marginTop: 20 }}
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
>
推送媒体后台
</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;