import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Card, Empty, Input, message, Modal, Select, Space, Table, Tag, Tooltip, Typography, } from 'antd'; import { PlayCircleOutlined, EyeOutlined, ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined, CloseCircleOutlined, SearchOutlined, VideoCameraOutlined, FileImageOutlined, } from '@ant-design/icons'; import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api'; import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types'; import { formatDate } from '../utils/formatDate'; import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus'; const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; // 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。 // 如果 VITE_API_BASE 配置为 http://host/api,这里需要去掉末尾 /api,避免资源被拼成 /api/images 导致 404。 const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); 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 AdminGenerationRecords: React.FC = () => { const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize] = useState(20); const [filterStatus, setFilterStatus] = useState(''); const [filterUserId, setFilterUserId] = useState(''); const [filterEngineId, setFilterEngineId] = useState(''); const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>(''); const [engineOptions, setEngineOptions] = useState>([]); const [reloadKey, setReloadKey] = useState(0); const [preview, setPreview] = useState(null); const [resourceState, setResourceState] = useState(EMPTY_RESOURCE_STATE); const [videoPlaying, setVideoPlaying] = useState(false); const videoRef = useRef(null); const load = useCallback(async () => { setLoading(true); try { const res = await getAdminGenerationRecords({ userId: filterUserId.trim() || undefined, status: filterStatus || undefined, engineId: filterEngineId || undefined, includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true', page, pageSize, }); setRecords((res.items || []).map((item: any) => ({ id: item.id, userId: item.userId, username: item.username, projectId: item.projectId, projectName: item.projectName, industry: item.industry, originalPrompt: item.originalPrompt, optimizedPrompt: item.optimizedPrompt, duration: item.duration, aspectRatio: item.aspectRatio, resolution: item.resolution, status: item.status, videoUrl: item.videoUrl, videoCoverUrl: item.videoCoverUrl, references: item.references, creditsCost: item.creditsCost || 0, textCreditsCost: item.textCreditsCost || 0, textTokensUsed: item.textTokensUsed || 0, videoTokensUsed: item.videoTokensUsed || 0, errorMessage: item.errorMessage, createdAt: item.createdAt, generatedAt: item.generatedAt, genType: item.genType, imageSize: item.imageSize, imageUrl: item.imageUrl, imageTokensUsed: item.imageTokensUsed || 0, imageProportion: item.imageProportion, imagePx: item.imagePx, engineId: item.engineId, engineName: item.engineName, engineSnapshot: item.engineSnapshot, includeMediaReferences: item.includeMediaReferences, }))); setTotal(res.total || 0); } catch { message.error('加载记录失败'); } finally { setLoading(false); } }, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]); useEffect(() => { load(); }, [load, reloadKey]); useEffect(() => { Promise.all([ getImageEngines({ includeDeleted: true }), getVideoEngines({ includeDeleted: true }), ]) .then(([imageEngines, videoEngines]) => { const items = [...(imageEngines || []), ...(videoEngines || [])]; const seen = new Set(); setEngineOptions(items.reduce>((acc, item: any) => { const id = String(item?.id || ''); if (!id || seen.has(id)) return acc; seen.add(id); const deletedSuffix = item?.deletedAt ? '(已删除)' : ''; acc.push({ value: id, label: item?.name ? `${item.name}${deletedSuffix} (${id})` : `${id}${deletedSuffix}` }); return acc; }, [])); }) .catch(() => setEngineOptions([])); }, []); useEffect(() => { if (!preview) { setResourceState(EMPTY_RESOURCE_STATE); return; } const references = preview.references || []; 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); setReloadKey((v) => v + 1); }; const handleOpenPreview = useCallback((record: AdminGenerationRecord) => { 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 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 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 columns = useMemo(() => [ { title: '用户', key: 'user', width: 120, render: (_: any, r: AdminGenerationRecord) => (
{r.username || '未知用户'}
{truncateId(r.userId)}
), }, { title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true, render: (v: string) => {v || '-'}, }, { title: '行业', dataIndex: 'industry', width: 100, ellipsis: true, render: (v: string) => {v || '-'}, }, { title: '类型', dataIndex: 'genType', width: 90, ellipsis: true, render: (v: string) => { const cfg = GEN_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null }; return {cfg.text}; }, }, { title: '提示词', key: 'prompt', ellipsis: true, render: (_: any, r: AdminGenerationRecord) => ( {r.originalPrompt || '-'} ), }, { title: '参数', key: 'params', width: 160, render: (_: any, r: AdminGenerationRecord) => ( r.genType === 'video' ? ( {r.duration ? {r.duration}s : null} {r.aspectRatio ? {r.aspectRatio} : null} {r.resolution ? {r.resolution} : null} {!r.duration && !r.aspectRatio && !r.resolution ? 待配置 : null} {r.engineName || r.engineId ? {r.engineName || truncateId(r.engineId || '')} : null} {r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''} ) : ( {r.imageSize ? {r.imageSize} : null} {r.imageProportion ? {r.imageProportion} : null} {r.imagePx ? {r.imagePx} : null} {!r.imageSize && !r.imageProportion && !r.imagePx ? 待配置 : null} {r.engineName || r.engineId ? {r.engineName || truncateId(r.engineId || '')} : null} {r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''} ) ), }, { title: '积分', key: 'credits', width: 120, render: (_: any, r: AdminGenerationRecord) => (
{r.textCreditsCost > 0 ? (
文字: {r.textCreditsCost}
) : null} {r.creditsCost > 0 ? (
{r.genType === 'video' ? '视频' : '图片'}: {r.creditsCost}
) : null} {r.textCreditsCost === 0 && r.creditsCost === 0 ? ( 0 ) : null}
), }, { title: '状态', dataIndex: 'status', width: 90, render: (v: string) => { const state = resolveGenerationUiState({ status: v }); const icon = state.isActive ? : (state.isSuccess ? : (state.isFailure ? : )); return {state.label}; }, }, { title: '阶段', dataIndex: 'pipelineStage', width: 150, render: (v: string) => {getGenerationStageLabel(v)}, }, { title: '时间', key: 'time', width: 150, render: (_: any, r: AdminGenerationRecord) => (
{safeDate(r.createdAt)}
{r.generatedAt ?
生成: {safeDate(r.generatedAt)}
: null}
), }, { title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminGenerationRecord) => ( ), }, ], [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 (
handleOpenExternalResource(preview.imageUrl, '生成图片')} style={{ position: 'relative', width: '100%', minHeight: 260, borderRadius: 12, background: '#f8f9fc', border: '1px solid #e2e8f0', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', }} > 生成图片 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} 条`, }} /> {/* Detail modal */} {preview?.genType === 'video' ? : } 生成记录详情 )} open={!!preview} onCancel={handleClosePreview} footer={null} width={900} destroyOnClose > {preview ? (
{/* User & Project info */}
用户 {preview.username || '-'}
项目 {preview.projectName || '-'}
类型 / 状态 {previewTypeConfig ? {previewTypeConfig.text} : null} {previewStatusConfig ? : (previewStatusConfig.isSuccess ? : (previewStatusConfig.isFailure ? : ))}>{previewStatusConfig.label} : null} {preview.pipelineStage ? {getGenerationStageLabel(preview.pipelineStage)} : null} {preview.videoUpscaleEnabled ? 已启用超分 : null}
{/* Prompts */}
原始提示词
{preview.originalPrompt || '-'}
优化后提示词
{preview.optimizedPrompt || '-'}
{/* Credits */}
{preview.textCreditsCost || 0} ({preview.textTokensUsed || 0} tokens) )} /> {preview.creditsCost || 0} {preview.genType === 'video' && preview.videoTokensUsed ? ( ({preview.videoTokensUsed} tokens) ) : null} {preview.genType === 'image' && preview.imageTokensUsed ? ( ({preview.imageTokensUsed} tokens) ) : null} )} />
{/* Video Params */} {preview.genType === 'video' ? ( preview.duration || preview.aspectRatio || preview.resolution ? (
) : (
视频参数待用户配置
) ) : null} {/* Image Params */} {preview.genType === 'image' ? ( preview.imageSize || preview.imageProportion || preview.imagePx ? (
) : (
图片参数待用户配置
) ) : null} {renderReferences()} {resolveGenerationUiState(preview).isSuccess ? (
{preview.genType === 'video' ? '生成视频' : '生成图片'} {preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
) : null} {/* Error message */} {resolveGenerationUiState(preview).isFailure && preview.errorMessage ? (
错误信息: {preview.errorMessage}
) : null} {/* Timestamps */}
创建: {safeDate(preview.createdAt)} 生成: {safeDate(preview.generatedAt)}
) : ( )}
); }; export default AdminGenerationRecords;