Files
video-gen/video-gen-app/src/pages/GeneratedRecord.tsx
T
2026-07-08 17:44:47 +08:00

2596 lines
140 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, useRef } from 'react';
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Modal, Table, DatePicker, Tabs, Transfer, Tooltip, Spin } from 'antd';
import dayjs from 'dayjs';
import JSZip from 'jszip';
import {
FilterOutlined,
VideoCameraOutlined,
PictureOutlined,
FolderOpenOutlined,
FileTextOutlined,
DownloadOutlined,
ClockCircleOutlined,
UploadOutlined,
ReloadOutlined,
StarOutlined,
PlayCircleOutlined,
DeleteOutlined,
LoadingOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
import { UploadResourceHistoryPanel } from '../components/uploadResource';
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
const { Search } = Input;
const { Text } = Typography;
const usePageAllLoaded = (callback: () => void) => {
const executedRef = React.useRef(false);
useEffect(() => {
if (executedRef.current) return;
const handleLoad = () => {
if (executedRef.current) return;
executedRef.current = true;
callback?.();
};
if (document.readyState === 'complete') {
handleLoad();
} else {
window.addEventListener('load', handleLoad);
}
return () => {
window.removeEventListener('load', handleLoad);
};
}, [callback]);
};
const GeneratedRecord: React.FC = () => {
const [isPageLoaded, setIsPageLoaded] = useState(false);
usePageAllLoaded(() => {
setIsPageLoaded(true);
});
const [searchParams] = useSearchParams();
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : searchParams.get('filterType') === 'upload_resource' ? 'upload_resource' : 'project';
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait' | 'upload_resource'>(initialFilterType);
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 [mediaLoadStatus, setMediaLoadStatus] = useState<Map<string, 'loading' | 'loaded' | 'error'>>(new Map());
const updateFilenameDebounceRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
return () => {
updateFilenameDebounceRef.current.forEach(timer => clearTimeout(timer));
updateFilenameDebounceRef.current.clear();
};
}, []);
const [oauthPage, setOauthPage] = useState(1);
const [oauthPageSize, setOauthPageSize] = useState(10);
const [oauthSelectOpens, setOauthSelectOpens] = useState<boolean[]>([false]);
const [accountTab, setAccountTab] = useState<'new' | 'history'>('new');
const [historyOAuthList, setHistoryOAuthList] = useState<any[]>([]);
const [historyOAuthLoading, setHistoryOAuthLoading] = useState(false);
const [selectedHistoryAccounts, setSelectedHistoryAccounts] = useState<any[]>([]);
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
const [isPreTest, setIsPreTest] = useState<string>('2');
const [preTestTemplate, setPreTestTemplate] = useState<string>('');
const [preTestTemplates, setPreTestTemplates] = useState<any[]>([]);
const [preTestTemplatesLoading, setPreTestTemplatesLoading] = 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());
// 从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 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;
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
if (isImage) {
return `${cleanBase}/static/${cleanPath}&w=300&q=50`;
}
return `${cleanBase}/${cleanPath}`;
};
// 下载文件
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);
};
// 检查视频尺寸是否符合480p要求(270×480或480×270
const checkVideoSize = (imagePx: string): boolean => {
if (!imagePx) return true;
const match = imagePx.match(/^(\d+)x(\d+)$/);
if (!match) return true;
const width = parseInt(match[1], 10);
const height = parseInt(match[2], 10);
const aspectRatio = width / height;
const isLandscape = width >= height;
if (isLandscape) {
const isValidWidth = width >= 480 && width <= 2560;
const isValidHeight = height >= 270 && height <= 1440;
const isValidRatio = aspectRatio >= 1.775 && aspectRatio <= 1.784;
return isValidWidth && isValidHeight && isValidRatio;
} else {
const isValidWidth = width >= 270 && width <= 1440;
const isValidHeight = height >= 480 && height <= 2560;
const isValidRatio = aspectRatio >= 0.555 && aspectRatio <= 0.564;
return isValidWidth && isValidHeight && isValidRatio;
}
};
// 多选相关函数
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 > 30) {
message.warning('最多只能单次下载30个文件');
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} 个文件`);
setIsSelectionMode(false);
setSelectedItems(new Set());
};
const handleDeleteSelected = async () => {
if (selectedItems.size === 0) {
message.warning('请先选择要删除的媒体');
return;
}
// 检查下载数量限制
if (selectedItems.size > 30) {
message.warning('最多只能单次删除30个文件');
return;
}
const selectedData = recordlist.flatMap((group: any) =>
group.items.filter((item: any) => selectedItems.has(getItemResourceId(item)))
);
let confirmContent: React.ReactNode = '';
if (filterType === 'shot_replicate') {
confirmContent = (
<div>
<div style={{ marginBottom: 8 }}>以下内容所连带的图片和视频都将被删除:</div>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{selectedData.map((item: any, index: number) => (
<li key={index} style={{ marginBottom: 4, color: '#334155' }}>
{item.shotReplicateProjectTitle} {item.shotSegmentLabel}
</li>
))}
</ul>
<div style={{ marginTop: 12 }}>确定删除吗?</div>
</div>
);
} else if (filterType === 'hot_opening_replicate') {
confirmContent = (
<div>
<div style={{ marginBottom: 8 }}>爆款开头复刻中以下内容所连带的图片和视频都将被删除:</div>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{selectedData.map((item: any, index: number) => (
<li key={index} style={{ marginBottom: 4, color: '#334155' }}>
{item.moduleProjectTitle}
</li>
))}
</ul>
<div style={{ marginTop: 12 }}>确定删除吗?</div>
</div>
);
} else {
confirmContent = `确定删除选中的 ${selectedItems.size} 条记录吗?`;
}
Modal.confirm({
title: '确认删除',
content: confirmContent,
okText: '确定删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
let historySource = '';
if (filterType === 'project') {
historySource = 'generation_record';
} else if (filterType === 'creation') {
historySource = 'chat_task';
} else if (filterType === 'hot_opening_replicate') {
historySource = 'hot_opening_replicate';
} else if (filterType === 'shot_replicate') {
historySource = 'shot_replicate';
}
const selectedContent: any[] = [];
recordlist.forEach((group: any) => {
group.items.forEach((item: any) => {
if (selectedItems.has(getItemResourceId(item))) {
selectedContent.push(item);
}
});
});
let idArr: any[] = [];
if (historySource === 'generation_record') {
idArr = selectedContent.map((item: any) => item.id);
} else if (historySource === 'chat_task') {
idArr = selectedContent.map((item: any) => item.id);
} else if (historySource === 'hot_opening_replicate') {
idArr = selectedContent.map((item: any) => item.moduleProjectId);
} else if (historySource === 'shot_replicate') {
idArr = selectedContent.map((item: any) => item.shotSegmentId);
}
const params = {
history_source: historySource,
ids: idArr,
};
try {
await deleteResourcesMaterial(params);
message.success('删除成功');
setIsSelectionMode(false);
setSelectedItems(new Set());
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
} catch (error) {
message.error('删除失败,请重试');
}
},
});
};
const loadPreTestTemplates = async () => {
setPreTestTemplatesLoading(true);
try {
const res = await getPreTestList({ page: 1, pageSize: 100 });
setPreTestTemplates(res.data?.data || res.data || []);
} catch (error) {
console.error('加载前测模板失败:', error);
} finally {
setPreTestTemplatesLoading(false);
}
};
const handleBatchUploadSelected = () => {
if (selectedItems.size === 0) {
message.warning('请先选择要推送的媒体');
return;
}
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setIsPreTest('2');
setPreTestTemplate('');
loadPreTestTemplates();
setUploadConfigModalVisible(true);
};
const handleSinglePushToMedia = () => {
if (!previewItem) return;
const resourceId = getItemResourceId(previewItem);
setSelectedItems(new Set([resourceId]));
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
setIsPreTest('2');
setPreTestTemplate('');
loadPreTestTemplates();
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 loadHistoryOAuthList = async () => {
setHistoryOAuthLoading(true);
try {
const [oauthRes, openTypeRes] = await Promise.all([
getAllOAuthAccountList(),
getOpenTypeAll(),
]);
const oauthData = oauthRes?.data || [];
setHistoryOAuthList(oauthData);
const openTypeData = openTypeRes?.data || [];
const map: Record<number, string> = {};
openTypeData.forEach((item: any) => {
map[item.openType] = item.typeName;
});
setOpenTypeMap(map);
} catch (error) {
console.error('加载历史授权账户列表失败:', error);
setHistoryOAuthList([]);
setOpenTypeMap({});
} finally {
setHistoryOAuthLoading(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 (selectedItems.size === 0) {
message.warning('请先选择要推送的媒体');
return;
}
const itemMap = new Map<string, any>();
recordlist.forEach((group: any) => {
group.items.forEach((item: any) => {
const resourceId = getItemResourceId(item);
itemMap.set(resourceId, item);
});
});
let tasks: {
advertiser_ids: string[];
resource_ids: string[];
is_pre_test: string;
pre_test_template: string;
oauth_id: string;
source_model: string;
}[] = [];
if (accountTab === 'new') {
const validOauthItems = selectedOauthItems.filter(item => item !== undefined);
if (validOauthItems.length === 0) {
message.warning('请先选择授权账户');
return;
}
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;
}
const sourceModelMap = new Map<string, string[]>();
for (const itemId of selectedItems) {
const item = itemMap.get(itemId);
let sourceModel: string;
if (item && hasGeneratedResourceId(item)) {
sourceModel = 'generated_resources';
}
// else {
// sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
// }
if (!sourceModelMap.has(sourceModel)) {
sourceModelMap.set(sourceModel, []);
}
sourceModelMap.get(sourceModel)!.push(itemId);
}
sourceModelMap.forEach((resourceIds, sourceModel) => {
tasks.push({
advertiser_ids: advertiserIds,
resource_ids: resourceIds,
oauth_id: oauthItem.value,
source_model: sourceModel,
is_pre_test: isPreTest,
pre_test_template: preTestTemplate,
});
});
}
} else {
if (selectedHistoryAccounts.length === 0) {
message.warning('请先选择历史账户');
return;
}
for (const account of selectedHistoryAccounts) {
const sourceModelMap = new Map<string, string[]>();
for (const itemId of selectedItems) {
const item = itemMap.get(itemId);
let sourceModel: string;
if (item && hasGeneratedResourceId(item)) {
sourceModel = 'generated_resources';
} else {
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
}
if (!sourceModelMap.has(sourceModel)) {
sourceModelMap.set(sourceModel, []);
}
sourceModelMap.get(sourceModel)!.push(itemId);
}
sourceModelMap.forEach((resourceIds, sourceModel) => {
tasks.push({
advertiser_ids: [account.advertiserId],
resource_ids: resourceIds,
oauth_id: String(account.oauthId),
source_model: sourceModel,
is_pre_test: isPreTest,
pre_test_template: preTestTemplate,
});
});
}
}
setUploading(true);
try {
const res = await asyncBatchUploadMaterial({ tasks });
if (res.errors?.length > 0) {
message.warning(res.message);
} else if (res.code === 0) {
message.success(res.message);
} else {
message.error(res.message);
}
setIsSelectionMode(false);
setSelectedItems(new Set());
setUploadConfigModalVisible(false);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setSelectedHistoryAccounts([]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
setIsPreTest('2');
setPreTestTemplate('');
} 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);
};
const getVideoDimensions = (videoUrl: string): Promise<string> => {
return new Promise((resolve) => {
const video = document.createElement('video');
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
let cleanPath = videoUrl.startsWith('/') ? videoUrl.slice(1) : videoUrl;
let cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
video.src = `${cleanBase}/${cleanPath}`;
video.crossOrigin = 'anonymous';
video.onloadedmetadata = () => {
const width = video.videoWidth;
const height = video.videoHeight;
video.remove();
resolve(`${width}x${height}`);
};
video.onerror = () => {
video.remove();
resolve('');
};
video.onabort = () => {
video.remove();
resolve('');
};
setTimeout(() => {
video.remove();
resolve('');
}, 5000);
});
};
const loadRecordList = () => {
if (filterType === 'private_portrait' || filterType === 'upload_resource') {
setLoading(false);
setRecordList([]);
return;
}
setLoading(true);
let historySource = '';
if (filterType === 'project') {
historySource = 'generation_record';
} else if (filterType === 'hot_opening_replicate') {
historySource = 'hot_opening_replicate';
} else if (filterType === 'shot_replicate') {
historySource = 'shot_replicate';
}
let parameters = `?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
if (historySource) {
parameters = `?gen_type=${filterMedia}&history_source=${historySource}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
}
if (selectedDate) {
let parameters = `${selectedDate}?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
if (historySource) {
parameters = `${selectedDate}?gen_type=${filterMedia}&history_source=${historySource}&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([]);
}
if (isPageLoaded) {
data.forEach(async (item: any) => {
if (item.videoUrl && item.videoUrl.trim()) {
const dimensions = await getVideoDimensions(item.videoUrl);
if (dimensions) {
setRecordList(prev => prev.map(group => ({
...group,
items: group.items.map((i: any) =>
i.id === item.id ? { ...i, imagePx: dimensions } : i
),
})));
}
}
});
}
}).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);
if (isPageLoaded) {
data.forEach(group => {
group.items.forEach(async (item: any) => {
if (item.videoUrl && item.videoUrl.trim()) {
const dimensions = await getVideoDimensions(item.videoUrl);
if (dimensions) {
setRecordList(prev => prev.map(g => ({
...g,
items: g.items.map((i: any) =>
i.id === item.id ? { ...i, imagePx: dimensions } : i
),
})));
}
}
});
});
}
}).catch((err) => {
if (Pagebreak.page === 1) {
setRecordList([]);
}
}).finally(() => {
setLoading(false);
});
}
};
useEffect(() => {
loadRecordList();
}, [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 historySource = '';
if (filterType === 'project') {
historySource = 'generation_record';
} else if (filterType === 'hot_opening_replicate') {
historySource = 'hot_opening_replicate';
} else if (filterType === 'shot_replicate') {
historySource = 'shot_replicate';
}
let parameters = `${time}?gen_type=${filterMedia}&page=${addpage}&page_size=${Pagebreak.pageSize}`;
if (historySource) {
parameters = `${time}?gen_type=${filterMedia}&history_source=${historySource}&page=${addpage}&page_size=${Pagebreak.pageSize}`;
}
try {
const res: any = await gethistoryItems(parameters);
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 className="content_box" >
{/* 操作栏:筛选 + 推送按钮 */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
<FilterOutlined style={{ color: '#090a0cff', fontSize: 14 }} />
<Space size={8} wrap={true}>
<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>
<Button
type={filterType === 'hot_opening_replicate' ? 'primary' : 'default'}
onClick={() => {
setFilterType('hot_opening_replicate');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'hot_opening_replicate'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'hot_opening_replicate' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'hot_opening_replicate' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<StarOutlined />}
>
爆款复刻
</Button>
<Button
type={filterType === 'shot_replicate' ? 'primary' : 'default'}
onClick={() => {
setFilterType('shot_replicate');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'shot_replicate'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'shot_replicate' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'shot_replicate' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<PlayCircleOutlined />}
>
拆镜复刻
</Button>
<Button
type={filterType === 'private_portrait' ? 'primary' : 'default'}
onClick={() => {
setFilterType('private_portrait');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'private_portrait'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'private_portrait' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'private_portrait' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UserOutlined />}
>
私域素材库
</Button>
<Button
type={filterType === 'upload_resource' ? 'primary' : 'default'}
onClick={() => {
setFilterType('upload_resource');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'upload_resource'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UploadOutlined />}
>
历史素材
</Button>
</Space>
</div>
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{/* 多选模式按钮 */}
{isSelectionMode ? (
<Space size={8} wrap={true}>
<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={() => handleDeleteSelected()}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #bf0b0a 0%, #ff8165 100%)', fontWeight: 600, padding: '8px 24px', color: '#fff' }}
>
删除 ({selectedItems.size})
</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>
{filterType === 'private_portrait' ? (
<PrivatePortraitLibraryPanel />
) : filterType === 'upload_resource' ? (
<UploadResourceHistoryPanel />
) : (
<>
{/* Second row filter: 视频 / 图片 */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 24,
padding: '12px 16px',
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 size={8} wrap={true}>
<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 */}
{loading ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
}}>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 24, color: '#64748b' }} spin />}
tip="加载中..."
size="large"
/>
</div>
) : 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) => {
const displayUrl = filterMedia === 'video' && item.videoCoverUrl
? buildUrl(item.videoCoverUrl, true)
: buildUrl(filterMedia === 'video' ? item.videoUrl : item.imageUrl, true);
const hasCover = filterMedia === 'video' && item.videoCoverUrl;
const showImage = hasCover || filterMedia === 'image';
const isSelectedItem = selectedItems.has(getItemResourceId(item));
return (
<div
key={getItemResourceId(item)}
className="media-card"
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 ? handleToggleSelect(getItemResourceId(item)) : handlePreview(item)}
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)';
}
}}
>
{showImage ? (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<img
src={displayUrl}
alt="预览"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: mediaLoadStatus.get(getItemResourceId(item)) === 'loaded' ? 1 : 0,
transition: 'opacity 0.3s',
}}
onLoad={() => {
setMediaLoadStatus(prev => {
const newMap = new Map(prev);
newMap.set(getItemResourceId(item), 'loaded');
return newMap;
});
}}
onError={() => {
setMediaLoadStatus(prev => {
const newMap = new Map(prev);
newMap.set(getItemResourceId(item), 'error');
return newMap;
});
}}
/>
{mediaLoadStatus.get(getItemResourceId(item)) !== 'loaded' && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
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',
animation: mediaLoadStatus.get(getItemResourceId(item)) === 'loading' ? 'pulse 1.5s infinite' : 'none',
}}>
{item.mediaType === 'video' ? (
<VideoCameraOutlined style={{ color: '#64748b', fontSize: 16 }} />
) : (
<PictureOutlined style={{ color: '#64748b', fontSize: 16 }} />
)}
</div>
</div>
)}
</div>
) : (
<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>
)}
{isSelectionMode && (
<>
<div
style={{
position: 'absolute',
top: 8,
left: 8,
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: isSelectedItem ? '#10b981' : 'rgba(255,255,255,0.9)',
border: isSelectedItem ? '2px solid #10b981' : '2px solid #d1d5db',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 10,
}}
onClick={(e) => {
e.stopPropagation();
handleToggleSelect(getItemResourceId(item));
}}
>
{isSelectedItem && (
<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>
{isSelectedItem && filterMedia === 'video' && item.imagePx && !checkVideoSize(item.imagePx) && (
<div style={{
position: 'absolute',
top: 36,
left: 8,
right: 8,
padding: '4px 8px',
backgroundColor: 'rgba(239, 68, 68, 0.9)',
color: '#fff',
fontSize: 12,
borderRadius: 4,
zIndex: 10,
}}>
视频实际尺寸可能不符合平台推送要求
</div>
)}
</>
)}
{!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>
<div
className="media-download-btn"
>
<Tooltip title="下载">
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'background 0.15s',
}}
onClick={(e) => {
e.stopPropagation();
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${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);
}}
>
<DownloadOutlined style={{ color: '#fff', fontSize: 14 }} />
</div>
</Tooltip>
</div>
</>
)}
{isSelectionMode && isSelectedItem && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '3px solid #10b981',
borderRadius: 4,
pointerEvents: 'none',
zIndex: 5,
}} />
)}
</div>
);
})}
</div>
{/* 分组内加载更多 */}
{group.total && group.total > group.items.length && (
<div style={{ padding: '12px 0', textAlign: 'left' }}>
<Button
onClick={() => handleGroupLoadMore(group.generatedDate, group.items, group.page)}
loading={loadingGroups.has(group.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={uploadHistoryModalVisible}
onCancel={() => setUploadHistoryModalVisible(false)}
footer={null}
width={800}
style={{ borderRadius: 8 }}
mask={{ closable: false }}
>
<div style={{ marginBottom: 16, display: 'flex', gap: 4 }}>
<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
/>
<div style={{ display: 'flex', gap: 4 }}>
<Button
type="primary"
onClick={handleUploadHistorySearch}
style={{ borderRadius: 8 }}
>
查询
</Button>
<Button
icon={<ReloadOutlined />}
onClick={handleUploadHistorySearch}
style={{ borderRadius: 8 }}
>
刷新
</Button>
</div>
</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: 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: 160,
ellipsis: true,
render: (note: string) => (
<span style={{ color: '#94a3b8' }}>
{note || '-'}
</span>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => formatDateTime(text),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => formatDateTime(text),
},
]}
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 && (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
{previewItem.title || '预览'}
</span>
</div>
}
open={previewVisible}
onCancel={handleClosePreview}
width={1200}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: '#fff', borderTop: '1px solid #f1f5f9' }}>
<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`;
const link = document.createElement('a');
link.href = url;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)', fontWeight: 600, padding: '8px 24px' }}
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
>
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? '资源已过期' : '下载'}
</Button>
<Button
type="primary"
icon={<DeleteOutlined />}
onClick={() => {
let deleteId = '';
let confirmContent: React.ReactNode = '';
if (filterType === 'shot_replicate') {
deleteId = previewItem.shotSegmentId;
confirmContent = (
<div>
<div style={{ marginBottom: 8 }}>以下内容所连带的图片和视频都将被删除:</div>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li style={{ marginBottom: 4, color: '#334155' }}>
{previewItem.shotReplicateProjectTitle} {previewItem.shotSegmentLabel}
</li>
</ul>
<div style={{ marginTop: 12 }}>确定删除吗?</div>
</div>
);
} else if (filterType === 'hot_opening_replicate') {
deleteId = previewItem.moduleProjectId;
confirmContent = (
<div>
<div style={{ marginBottom: 8 }}>爆款开头复刻中以下内容所连带的图片和视频都将被删除:</div>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li style={{ marginBottom: 4, color: '#334155' }}>
{previewItem.moduleProjectTitle}
</li>
</ul>
<div style={{ marginTop: 12 }}>确定删除吗?</div>
</div>
);
} else {
deleteId = previewItem.id;
confirmContent = '确定删除该记录吗?';
}
if (!deleteId) {
message.warning('无法获取删除ID');
return;
}
Modal.confirm({
title: '确认删除',
content: confirmContent,
okText: '确定删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
let historySource = '';
if (filterType === 'project') {
historySource = 'generation_record';
} else if (filterType === 'creation') {
historySource = 'chat_task';
} else if (filterType === 'hot_opening_replicate') {
historySource = 'hot_opening_replicate';
} else if (filterType === 'shot_replicate') {
historySource = 'shot_replicate';
}
let params = {
history_source: historySource,
ids: [deleteId],
}
try {
await deleteResourcesMaterial(params); // 调用删除接口
message.success('删除成功');
handleClosePreview();
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
} catch (error) {
message.error('删除失败,请重试');
}
},
});
}}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #bf0b0a 0%, #ff8165 100%)', fontWeight: 600, padding: '8px 24px' }}
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
>
删除
</Button>
<Button
onClick={handleSinglePushToMedia}
style={{ borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#64748b', fontWeight: 600, padding: '8px 24px' }}
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
>
推送媒体后台
</Button>
</div>
}
style={{ borderRadius: 16, overflow: 'hidden' }}
styles={{
body: { height: 500, display: 'flex', flexDirection: 'column', padding: 0 },
header: { background: '#fff', borderBottom: '1px solid #f1f5f9', padding: '16px 24px' },
mask: { backgroundColor: 'rgba(0, 0, 0, 0.5)' },
}}
>
<div style={{
width: '100%',
display: 'flex',
height: '100%',
background: '#f8fafc',
}}>
<div style={{
width: '70%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
}}>
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? (
<div style={{ textAlign: 'center', padding: '40px', backgroundColor: '#fff', borderRadius: 12 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
<p style={{ fontSize: 15, color: '#ef4444', fontWeight: 500, marginBottom: 8 }}>资源已过期</p>
<p style={{ fontSize: 13, color: '#94a3b8' }}>请刷新页面重新加载</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: '100%',
borderRadius: 12,
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: '100%',
objectFit: 'contain',
borderRadius: 12,
}}
/>
)}
</div>
<div style={{
width: '30%',
height: '100%',
overflowY: 'auto',
scrollbarWidth: 'none',
backgroundColor: '#fff',
padding: 24,
borderLeft: '1px solid #f1f5f9',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
<div style={{ width: 2, height: 18, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 1 }} />
<Typography.Text strong style={{ fontSize: 15, color: '#1e293b', fontWeight: 600 }}>
文件信息
</Typography.Text>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>类型</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{filterMedia === 'video' ? '视频' : '图片'}
</span>
</div>
{filterType === 'hot_opening_replicate' && (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 4 }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>类属</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500, wordBreak: 'break-all', lineHeight: '1.5' }}>
爆款复刻——{previewItem.moduleProjectTitle}
</span>
</div>
)}
{filterType === 'shot_replicate' && (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 4 }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>类属</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500, wordBreak: 'break-all', lineHeight: '1.5' }}>
{previewItem.shotReplicateProjectTitle} {previewItem.shotSegmentLabel}
</span>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>请求</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500, wordBreak: 'break-all', lineHeight: '1.5' }}>
{previewItem.originalPrompt}
</span>
</div>
{filterMedia === 'image' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>比例</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{previewItem.imageProportion}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>分辨率</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{previewItem.imageSize}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>尺寸</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{previewItem.imagePx}
</span>
</div>
</>
)}
{filterMedia === 'video' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>比例</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{previewItem.aspectRatio}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>分辨率</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{previewItem.resolution}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>时长</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{`${previewItem.duration}秒` || '-'}
</span>
</div>
</>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>创建时间</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{formatDateTime(previewItem.createdAt || previewItem.generatedDate)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#94a3b8', fontSize: 13, fontWeight: 500 }}>生成引擎</span>
<span style={{ color: '#334155', fontSize: 13, fontWeight: 600 }}>
{previewItem.engine || previewItem.engineName || '-'}
</span>
</div>
</div>
<div style={{ borderTop: '1px solid #f1f5f9', margin: '20px 0' }} />
{previewItem.mediaReferences && previewItem.mediaReferences.length > 0 && !['hot_opening_replicate', 'shot_replicate'].includes(filterType) && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ width: 2, height: 18, background: 'linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%)', borderRadius: 1 }} />
<Typography.Text strong style={{ fontSize: 15, color: '#1e293b', fontWeight: 600 }}>
依靠附件
</Typography.Text>
</div>
{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: 10,
padding: '10px 12px',
borderRadius: 8,
cursor: 'pointer',
backgroundColor: '#f8fafc',
marginBottom: 8,
transition: 'all 0.25s ease',
border: '1px solid transparent',
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.backgroundColor = '#e0f2fe';
el.style.borderColor = '#bae6fd';
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.backgroundColor = '#f8fafc';
el.style.borderColor = 'transparent';
}}
>
{item.type === 'image' ? (
<PictureOutlined style={{ color: '#3b82f6', fontSize: 16 }} />
) : (
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
)}
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
{item.name || `媒体${index + 1}`}
</span>
</div>
))}
</>
)}
</div>
</div>
</Modal>
)}
{/* 推送配置弹窗 */}
<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);
}}
onBlur={() => {
const newName = materialFileNames.get(itemId) || item?.fileName || '';
if (newName.trim()) {
handleUpdateFileName(itemId, newName);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const newName = materialFileNames.get(itemId) || item?.fileName || '';
if (newName.trim()) {
handleUpdateFileName(itemId, newName);
}
}
}}
placeholder="输入新名称"
style={{ width: 200, borderRadius: 4 }}
size="small"
/>
</div>
);
});
})()}
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Text style={{ fontSize: 14, color: '#475569' }}>是否开启前测:</Typography.Text>
<Select
value={isPreTest}
onChange={(value) => {
setIsPreTest(value);
if (value !== '1') {
setPreTestTemplate('');
} else {
getDefaultPreTest().then((res) => {
setPreTestTemplate(res?.data?.id || '');
}).catch(() => {
});
}
}}
style={{ width: 120 }}
options={[
{ value: '1', label: '是' },
{ value: '2', label: '否' },
]}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Text style={{ fontSize: 14, color: '#475569' }}>前测模板:</Typography.Text>
<Select
value={preTestTemplate}
onChange={(value) => setPreTestTemplate(value)}
style={{ width: 200 }}
disabled={isPreTest !== '1'}
loading={preTestTemplatesLoading}
placeholder={isPreTest !== '1' ? '请先开启前测' : '选择前测模板'}
options={preTestTemplates.map((item) => ({
value: item.id,
label: item.name,
}))}
/>
</div>
</div>
<Tabs
size="small"
activeKey={accountTab}
onChange={(key) => {
setAccountTab(key as 'new' | 'history');
if (key === 'history') {
loadHistoryOAuthList();
}
}}
items={[
{
key: 'new',
label: '新增账户',
},
{
key: 'history',
label: '历史账户',
},
]}
/>
{accountTab === 'new' && (
<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: '授权账户角色',
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>,
},
{
title: '授权用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
width: 120,
},
]}
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>
)}
{accountTab === 'history' && (
<div style={{ padding: 8 }}>
{historyOAuthLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
) : historyOAuthList.length === 0 ? (
<Empty description="暂无可选账户" />
) : (
<Transfer
style={{ width: '100%' }}
dataSource={historyOAuthList
.filter(item => item.advertiserId)
.map((item, index) => ({
key: String(item.advertiserId) || `item-${index}`,
title: String(item.advertiserId),
description: item.advertiserName || '',
...item,
}))}
targetKeys={selectedHistoryAccounts
.filter(item => item.advertiserId)
.map(item => String(item.advertiserId))}
onChange={(targetKeys) => {
const newSelected = historyOAuthList.filter(item =>
item.advertiserId && targetKeys.includes(String(item.advertiserId))
);
setSelectedHistoryAccounts(newSelected);
}}
titles={['可选账户列表', '已选账户列表']}
showSearch
filterOption={(inputValue, item) =>
String(item.advertiserId).toLowerCase().includes(inputValue.toLowerCase()) ||
(item.advertiserName && item.advertiserName.toLowerCase().includes(inputValue.toLowerCase()))
}
>
{({
direction,
filteredItems,
onItemSelect,
onItemSelectAll,
selectedKeys: listSelectedKeys,
disabled: listDisabled,
}) => {
const columns = [
{
title: '账户ID',
dataIndex: 'advertiserId',
key: 'advertiserId',
width: 120,
},
{
title: '账户名称',
dataIndex: 'advertiserName',
key: 'advertiserName',
width: 150,
},
];
const rowSelection = {
getCheckboxProps: () => ({ disabled: listDisabled }),
onChange: (selectedRowKeys: string[]) => {
onItemSelectAll(selectedRowKeys, 'replace');
},
selectedRowKeys: listSelectedKeys,
selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT, Table.SELECTION_NONE],
};
return (
<Table
rowSelection={rowSelection}
columns={columns}
dataSource={filteredItems}
size="small"
style={{ pointerEvents: listDisabled ? 'none' : undefined }}
pagination={false}
scroll={{ x: 'max-content', y: 400 }}
onRow={({ key, disabled: itemDisabled }) => ({
onClick: () => {
if (itemDisabled || listDisabled) {
return;
}
onItemSelect(key, !listSelectedKeys.includes(key));
},
})}
/>
);
}}
</Transfer>
)}
</div>
)}
{/* 操作按钮 */}
<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 || (accountTab === 'new' ? accountIdLists.every(list => list.length === 0) : selectedHistoryAccounts.length === 0)}
style={{ borderRadius: 8 }}
>
{uploading ? '推送中...' : '开始推送'}
</Button>
</div>
</div>
</Modal>
</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;