import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Card, DatePicker, Empty, Input, message, Modal, Select, Space, Table, Tag, Tooltip, Typography, } from 'antd'; import { AudioOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, EyeOutlined, FileImageOutlined, LinkOutlined, LoadingOutlined, PlayCircleOutlined, RobotOutlined, SearchOutlined, VideoCameraOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import { getAdminGenerationAiTasks, getImageEngines, getVideoEngines } from '../api'; import type { GenerationAiImageEngine, GenerationAIMediaReference, GenerationAITaskOut, GenerationAiVideoEngine, } from '../types'; import { formatDate } from '../utils/formatDate'; import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid'; import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus'; const { RangePicker } = DatePicker; const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; // 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。 // 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。 const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); const PAGE_SIZE = 20; /** 从 engineSnapshot 中提取引擎展示名称。 */ const getEngineName = (snapshot: Record | null | undefined): string | null => { if (!snapshot) return null; return (snapshot.name as string) || (snapshot.modelName as string) || (snapshot.id as string) || null; }; type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid'; interface PreviewResourceState { image: ResourceState; video: ResourceState; videoCover: ResourceState; references: Record; } const EMPTY_RESOURCE_STATE: PreviewResourceState = { image: 'empty', video: 'empty', videoCover: 'empty', references: {}, }; const GEN_TYPE_MAP: Record = { image: { text: '图片', color: 'purple', icon: }, video: { text: '视频', color: 'geekblue', icon: }, }; const isAbsoluteLikeUrl = (url: string): boolean => ( /^(https?:)?\/\//i.test(url) || /^(blob|data):/i.test(url) ); const isBlobUrl = (url?: string | null): boolean => !!url && /^blob:/i.test(url.trim()); const apiUrl = (url?: string | null): string => { if (!url) return ''; const value = String(url).trim(); if (!value) return ''; if (isAbsoluteLikeUrl(value)) return value; if (!RESOURCE_BASE) { return value.startsWith('/') ? value : `/${value}`; } return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`; }; const truncateId = (value?: string | null): string => { if (!value) return '-'; return value.length > 12 ? `${value.slice(0, 8)}...` : value; }; const safeDate = (value?: string | null): string => { if (!value) return '-'; return formatDate(value); }; const isEmptyValue = (value: React.ReactNode): boolean => ( value === null || value === undefined || value === '' ); const isUrlExpired = (url?: string | null): boolean => { if (!url || isBlobUrl(url)) return false; try { const parsed = new URL(apiUrl(url), window.location.origin); const expireValue = parsed.searchParams.get('exp') || parsed.searchParams.get('expires') || parsed.searchParams.get('expire') || parsed.searchParams.get('expires_at') || parsed.searchParams.get('x-expires'); if (!expireValue) return false; const expireNumber = Number(expireValue); if (!Number.isFinite(expireNumber)) return false; const expireMs = expireNumber > 10_000_000_000 ? expireNumber : expireNumber * 1000; return Date.now() >= expireMs; } catch { return false; } }; const getInitialResourceState = (url?: string | null): ResourceState => { if (!url) return 'empty'; if (isBlobUrl(url)) return 'invalid'; if (isUrlExpired(url)) return 'invalid'; return 'checking'; }; const getReferenceUrl = (ref: GenerationAIMediaReference): string | undefined => { const value = ref.url || ref.mediaUrl || ref.fileUrl; return typeof value === 'string' && value.trim() ? value.trim() : undefined; }; const getReferenceType = (ref: GenerationAIMediaReference): string => { const rawType = String(ref.type || ref.mediaType || ref.mimeType || '').toLowerCase(); const url = getReferenceUrl(ref)?.toLowerCase() || ''; if (rawType.includes('video') || /\.(mp4|mov|webm|m4v)(\?|$)/i.test(url)) return 'video'; if (rawType.includes('image') || /\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(url)) return 'image'; return rawType || 'unknown'; }; const getReferenceKey = (ref: GenerationAIMediaReference, index: number): string => { const refUrl = getReferenceUrl(ref) || 'empty'; return `${index}-${refUrl}`; }; const getMediaInvalidText = (url?: string | null, type = '资源'): string => { if (isBlobUrl(url)) return '本地临时素材已失效'; if (isUrlExpired(url)) return `${type}链接已超时,请刷新列表或重新搜索后再查看`; return `${type}加载失败,请刷新列表或重新搜索后再查看`; }; const MediaPlaceholder: React.FC<{ text: string; minHeight?: number; compact?: boolean; action?: React.ReactNode; }> = ({ text, minHeight = 240, compact = false, action }) => (
{text} {action}
); const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label, value }) => (
{label} {isEmptyValue(value) ? '-' : value}
); const todayStart = () => dayjs().startOf('day'); const todayEnd = () => dayjs().endOf('day'); const AdminGenerationAiRecords: React.FC = () => { const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [filterStatus, setFilterStatus] = useState(''); const [filterGenType, setFilterGenType] = useState(''); const [filterEngineId, setFilterEngineId] = useState(''); const [inputUserId, setInputUserId] = useState(''); const [inputUserName, setInputUserName] = useState(''); const [queryUserId, setQueryUserId] = useState(''); const [queryUserName, setQueryUserName] = useState(''); const [queryEngineId, setQueryEngineId] = useState(''); const [createdRange, setCreatedRange] = useState([todayStart(), todayEnd()]); const [queryCreatedRange, setQueryCreatedRange] = useState([todayStart(), todayEnd()]); const [reloadKey, setReloadKey] = useState(0); const [engineListLoading, setEngineListLoading] = useState(false); const [imageEngines, setImageEngines] = useState([]); const [videoEngines, setVideoEngines] = useState([]); const [preview, setPreview] = useState(null); const [resourceState, setResourceState] = useState(EMPTY_RESOURCE_STATE); const [videoPlaying, setVideoPlaying] = useState(false); const videoRef = useRef(null); // 资源预览弹窗(附件 / 最终结果) const [resourcePreview, setResourcePreview] = useState<{ url: string; type: 'image' | 'video'; title: string; } | null>(null); const load = useCallback(async () => { setLoading(true); try { const res = await getAdminGenerationAiTasks({ genType: filterGenType || undefined, status: filterStatus || undefined, userId: queryUserId || undefined, userName: queryUserName || undefined, engineId: queryEngineId || undefined, createdStart: queryCreatedRange?.[0]?.format?.('YYYY-MM-DDTHH:mm:ss'), createdEnd: queryCreatedRange?.[1]?.format?.('YYYY-MM-DDTHH:mm:ss'), page, pageSize: PAGE_SIZE, }); setRecords(res.items || []); setTotal(res.total || 0); } catch (e: any) { message.error(e?.message || '加载创作记录失败'); } finally { setLoading(false); } }, [filterGenType, filterStatus, page, queryUserId, queryUserName, queryEngineId, queryCreatedRange]); useEffect(() => { load(); }, [load, reloadKey]); useEffect(() => { let cancelled = false; const loadEngineOptions = async () => { setEngineListLoading(true); try { const [images, videos] = await Promise.all([ getImageEngines({ includeDeleted: true }), getVideoEngines({ includeDeleted: true }), ]); if (!cancelled) { setImageEngines(images || []); setVideoEngines(videos || []); } } catch (error: any) { if (!cancelled) { message.error(error?.message || '加载模型引擎列表失败'); } } finally { if (!cancelled) setEngineListLoading(false); } }; void loadEngineOptions(); return () => { cancelled = true; }; }, []); const engineOptions = useMemo(() => { const toOption = ( engine: GenerationAiImageEngine | GenerationAiVideoEngine, type: 'image' | 'video', ) => { const deleted = Boolean(engine.deletedAt); const typeText = type === 'image' ? '图片' : '视频'; const deletedText = deleted ? '[已删除]' : ''; const detailText = [engine.name, engine.modelName, engine.provider, engine.id] .filter(Boolean) .join(' / '); const label = `[${typeText}]${deletedText} ${detailText}`; return { value: engine.id, label, searchText: [engine.id, engine.name, engine.modelName, engine.provider, typeText, deleted ? '已删除' : ''] .filter(Boolean) .join(' ') .toLowerCase(), }; }; const merged = [ ...imageEngines.map((engine) => toOption(engine, 'image')), ...videoEngines.map((engine) => toOption(engine, 'video')), ]; return Array.from(new Map(merged.map((option) => [option.value, option])).values()); }, [imageEngines, videoEngines]); useEffect(() => { if (!preview) { setResourceState(EMPTY_RESOURCE_STATE); return; } const references = preview.mediaReferences || []; const initialReferences = references.reduce>((acc, ref, index) => { const refUrl = getReferenceUrl(ref); acc[getReferenceKey(ref, index)] = getInitialResourceState(refUrl); return acc; }, {}); if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } setVideoPlaying(false); setResourceState({ image: getInitialResourceState(preview.imageUrl), video: getInitialResourceState(preview.videoUrl), videoCover: getInitialResourceState(preview.videoCoverUrl), references: initialReferences, }); }, [preview]); const handleSearch = () => { setPage(1); setQueryUserId(inputUserId.trim()); setQueryUserName(inputUserName.trim()); setQueryEngineId(filterEngineId); setQueryCreatedRange(createdRange); setReloadKey((v) => v + 1); }; const handleReset = () => { setFilterStatus(''); setFilterGenType(''); setFilterEngineId(''); setInputUserId(''); setInputUserName(''); setQueryUserId(''); setQueryUserName(''); setQueryEngineId(''); const defaultRange = [todayStart(), todayEnd()]; setCreatedRange(defaultRange); setQueryCreatedRange(defaultRange); setPage(1); setReloadKey((v) => v + 1); }; const handleOpenPreview = useCallback((record: GenerationAITaskOut) => { setPreview(record); }, []); const handleClosePreview = () => { if (videoRef.current) { videoRef.current.pause(); } setVideoPlaying(false); setPreview(null); }; const patchResourceState = (patch: Partial) => { setResourceState((prev) => ({ ...prev, ...patch })); }; const patchReferenceState = (key: string, state: ResourceState) => { setResourceState((prev) => ({ ...prev, references: { ...prev.references, [key]: state, }, })); }; const handlePlayVideo = () => { if (!preview?.videoUrl) return; if (isUrlExpired(preview.videoUrl)) { patchResourceState({ video: 'invalid' }); message.warning('视频链接已超时,请刷新列表或重新搜索后再查看'); return; } setVideoPlaying(true); window.setTimeout(() => { videoRef.current?.play().catch(() => { setVideoPlaying(false); patchResourceState({ video: 'invalid' }); message.warning('视频播放失败,请确认资源链接是否仍然有效'); }); }, 0); }; const handleOpenExternalResource = (url?: string | null, type = '素材') => { if (!url) { message.warning(`${type}链接为空,暂无法查看`); return; } if (isBlobUrl(url)) { message.warning('本地临时素材已失效,暂无法查看'); return; } if (isUrlExpired(url)) { message.warning(`${type}链接已超时,请刷新列表或重新搜索后再查看`); return; } window.open(apiUrl(url), '_blank', 'noopener,noreferrer'); }; /** 弹窗预览资源(图片/视频) */ const handlePreviewResource = (url: string, type: 'image' | 'video', title: string) => { if (isBlobUrl(url)) { message.warning('本地临时素材已失效,暂无法查看'); return; } if (isUrlExpired(url)) { message.warning('链接已超时,请刷新列表或重新搜索后再查看'); return; } setResourcePreview({ url: apiUrl(url), type, title }); }; const handleCloseResourcePreview = () => setResourcePreview(null); const columns = useMemo(() => [ { title: '用户', key: 'user', width: 150, render: (_: any, r: GenerationAITaskOut) => (
{r.userName || '未知用户'}
{truncateId(r.userId)}
), }, { title: '类型', dataIndex: 'genType', width: 90, render: (v: string) => { const cfg = GEN_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null }; return {cfg.text}; }, }, { title: '生成数量', key: 'generationCount', width: 150, render: (_: any, r: GenerationAITaskOut) => { const count = Math.max(1, Number(r.generationCount || 1)); if (count === 1) return 1份; const children = r.childItems || []; const completed = children.filter((item) => resolveGenerationUiState(item).isSuccess).length; const failed = children.filter((item) => resolveGenerationUiState(item).isFailure).length; const deleted = children.filter((item) => (item.displayStatus || item.status) === 'deleted').length; return ( {count}份 {completed}完成{failed ? ` / ${failed}失败` : ''}{deleted ? ` / ${deleted}删除` : ''} ); }, }, { title: '引擎', key: 'engine', width: 160, render: (_: any, r: GenerationAITaskOut) => { const name = getEngineName(r.engineSnapshot as any); return ( } color="cyan">{name || r.engineId || '-'} ); }, }, { title: '附件', key: 'references', width: 100, render: (_: any, r: GenerationAITaskOut) => { const refs = r.mediaReferences || []; if (refs.length === 0) return ; return ( {refs.map((ref, idx) => { const refUrl = getReferenceUrl(ref); const refType = getReferenceType(ref); const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`; if (!refUrl) return null; const icon = refType === 'video' ? : refType === 'audio' ? : ; return ( ); } if (r.genType === 'image' && r.imageUrl) { return ( ); } return ; }, }, { title: '提示词', key: 'prompt', ellipsis: true, render: (_: any, r: GenerationAITaskOut) => ( {r.originalPrompt || '-'} ), }, { title: '参数', key: 'params', width: 180, render: (_: any, r: GenerationAITaskOut) => ( r.genType === 'video' ? ( r.duration || r.aspectRatio || r.resolution ? ( {r.duration ? {r.duration}s : null} {r.aspectRatio ? {r.aspectRatio} : null} {r.resolution ? {r.resolution} : null} ) : 无参数 ) : ( r.imageSize || r.imageProportion || r.imagePx ? ( {r.imageSize ? {r.imageSize} : null} {r.imageProportion ? {r.imageProportion} : null} {r.imagePx ? {r.imagePx} : null} ) : 无参数 ) ), }, { title: '积分', key: 'credits', width: 130, render: (_: any, r: GenerationAITaskOut) => (
总: {r.creditsCost || 0}
{r.textCreditsCost > 0 ?
文字: {r.textCreditsCost}
: null}
), }, { title: '状态', dataIndex: 'status', width: 100, render: (v: string) => { const state = resolveGenerationUiState({ status: v }); const icon = state.isActive ? : (state.isSuccess ? : (state.isFailure ? : )); return {state.label}; }, }, { title: '阶段', dataIndex: 'pipelineStage', width: 120, render: (v: string) => {getGenerationStageLabel(v)}, }, { title: '时间', key: 'time', width: 170, render: (_: any, r: GenerationAITaskOut) => (
{safeDate(r.createdAt)}
{r.generatedAt ?
生成: {safeDate(r.generatedAt)}
: null}
), }, { title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: GenerationAITaskOut) => ( ), }, ], [handleOpenPreview]); const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null; const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null; const renderResultImage = () => { if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null; if (!preview.imageUrl) { return ; } if (resourceState.image === 'invalid') { return ; } const src = apiUrl(preview.imageUrl); return (
生成图片 patchResourceState({ image: 'valid' })} onError={() => patchResourceState({ image: 'invalid' })} style={{ display: resourceState.image === 'valid' ? 'block' : 'none', width: '100%', maxHeight: 560, objectFit: 'contain', background: '#fff', }} /> {resourceState.image === 'checking' ? ( ) : null}
); }; const renderVideoMask = () => { if (!preview || videoPlaying || resourceState.video === 'invalid') return null; const canPlay = !!preview.videoUrl; const playButton = canPlay ? (
`共 ${t} 条`, onChange: (p) => setPage(p), }} /> {preview?.genType === 'video' ? : } 创作记录详情 )} open={!!preview} onCancel={handleClosePreview} footer={null} width={900} destroyOnClose > {preview ? (
{previewTypeConfig ? {previewTypeConfig.text} : null} {previewStatusConfig ? : (previewStatusConfig.isSuccess ? : (previewStatusConfig.isFailure ? : ))}>{previewStatusConfig.label} : null} {preview.pipelineStage ? {getGenerationStageLabel(preview.pipelineStage)} : null} {/*{preview.generationMode ? {preview.generationMode} : null}*/}
原始提示词
{preview.originalPrompt || '-'}
{preview.genType === 'video' ? (
) : (
)} {preview.engineSnapshot ? (
引擎快照
) : null}
{preview.creditsCost || 0}} />
{renderReferences()} {/* 附件快速查看区 */} {(preview.mediaReferences && preview.mediaReferences.length > 0) ? (
附件快速查看(点击新窗口打开) {preview.mediaReferences.map((ref, idx) => { const refUrl = getReferenceUrl(ref); const refType = getReferenceType(ref); const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`; if (!refUrl) return null; return ( ); })}
) : null}
生成资源(共 {preview.generationCount || 1} 份)
{previewStatusConfig?.isFailure && preview.errorMessage ? (
错误信息: {preview.errorMessage}
) : null}
创建: {safeDate(preview.createdAt)} 生成: {safeDate(preview.generatedAt)}
) : ( )}
{/* 资源预览弹窗(附件 / 最终结果) */} {resourcePreview ? ( resourcePreview.type === 'video' ? ( ); }; export default AdminGenerationAiRecords;