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 { CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, EyeOutlined, FileImageOutlined, LoadingOutlined, PlayCircleOutlined, SearchOutlined, VideoCameraOutlined, } from '@ant-design/icons'; import { getAdminGenerationAiTasks } from '../api'; import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types'; import { formatDate } from '../utils/formatDate'; 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; 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 STATUS_MAP: Record = { pending: { color: 'default', text: '待处理', icon: }, generating: { color: 'warning', text: '生成中', icon: }, completed: { color: 'success', text: '已完成', icon: }, failed: { color: 'error', text: '失败', icon: }, }; const PIPELINE_STAGE_MAP: Record = { timeout: '任务超时', queued: '已入队', preparing: '准备中', creating_provider_task: '创建任务中', waiting_remote: '等待生成', result_ready: '结果就绪', downloading: '下载中', done: '完成', download_failed: '下载失败', polling: '轮询中', failed: '失败', }; 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 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 [inputUserId, setInputUserId] = useState(''); const [inputUserName, setInputUserName] = useState(''); const [queryUserId, setQueryUserId] = useState(''); const [queryUserName, setQueryUserName] = 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 getAdminGenerationAiTasks({ genType: filterGenType || undefined, status: filterStatus || undefined, userId: queryUserId || undefined, userName: queryUserName || undefined, 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]); useEffect(() => { load(); }, [load, reloadKey]); 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()); 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 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: '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 cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null }; return {cfg.text}; }, }, { title: '阶段', dataIndex: 'pipelineStage', width: 120, render: (v: string) => {PIPELINE_STAGE_MAP[v] || 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 ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null; const renderResultImage = () => { if (!preview || preview.genType !== 'image' || preview.status !== 'completed') 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.text} : null} {preview.pipelineStage ? {PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage} : null} {/*{preview.generationMode ? {preview.generationMode} : null}*/}
原始提示词
{preview.originalPrompt || '-'}
{preview.genType === 'video' ? (
) : (
)} {preview.engineSnapshot ? (
引擎快照
) : null}
{preview.creditsCost || 0}} />
{renderReferences()} {preview.status === 'completed' ? (
{preview.genType === 'video' ? '生成视频' : '生成图片'} {preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
) : null} {preview.status === 'failed' && preview.errorMessage ? (
错误信息: {preview.errorMessage}
) : null}
创建: {safeDate(preview.createdAt)} 生成: {safeDate(preview.generatedAt)}
) : ( )}
); }; export default AdminGenerationAiRecords;