2596 lines
140 KiB
TypeScript
2596 lines
140 KiB
TypeScript
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;
|