Files
video-gen/video-gen-admin/src/pages/AdminGenerationAiRecords.tsx
T
2026-07-03 10:28:04 +08:00

1179 lines
44 KiB
TypeScript

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 {
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
FileImageOutlined,
LinkOutlined,
LoadingOutlined,
PlayCircleOutlined,
RobotOutlined,
SearchOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { formatDate } from '../utils/formatDate';
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<string, unknown> | 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<string, ResourceState>;
}
const EMPTY_RESOURCE_STATE: PreviewResourceState = {
image: 'empty',
video: 'empty',
videoCover: 'empty',
references: {},
};
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
pending: { color: 'default', text: '待处理', icon: <ClockCircleOutlined /> },
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
};
const PIPELINE_STAGE_MAP: Record<string, string> = {
timeout: '任务超时',
queued: '已入队',
preparing: '准备中',
creating_provider_task: '创建任务中',
waiting_remote: '等待生成',
result_ready: '结果就绪',
downloading: '下载中',
done: '完成',
download_failed: '下载失败',
polling: '轮询中',
failed: '失败',
};
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
};
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 }) => (
<div
style={{
width: '100%',
minHeight: compact ? undefined : minHeight,
height: compact ? '100%' : undefined,
borderRadius: compact ? 10 : 12,
background: '#f8f9fc',
border: '1px dashed #cbd5e1',
color: '#64748b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: compact ? 4 : 10,
textAlign: 'center',
// padding: compact ? 6 : 18,
}}
>
<Typography.Text style={{ color: '#64748b', fontSize: compact ? 11 : 13 }}>{text}</Typography.Text>
{action}
</div>
);
const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label, value }) => (
<div style={{ flex: 1, minWidth: 120 }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>{label}</Typography.Text>
<Typography.Text strong style={{ fontSize: 14 }}>{isEmptyValue(value) ? '-' : value}</Typography.Text>
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationAiRecords: React.FC = () => {
const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterGenType, setFilterGenType] = useState<string>('');
const [filterEngineId, setFilterEngineId] = useState<string>('');
const [inputUserId, setInputUserId] = useState<string>('');
const [inputUserName, setInputUserName] = useState<string>('');
const [queryUserId, setQueryUserId] = useState<string>('');
const [queryUserName, setQueryUserName] = useState<string>('');
const [queryEngineId, setQueryEngineId] = useState<string>('');
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [reloadKey, setReloadKey] = useState(0);
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(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]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
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(() => {
if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE);
return;
}
const references = preview.mediaReferences || [];
const initialReferences = references.reduce<Record<string, ResourceState>>((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<PreviewResourceState>) => {
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) => (
<div>
<Typography.Text strong style={{ fontSize: 13 }}>{r.userName || '未知用户'}</Typography.Text>
<div style={{ fontSize: 11, color: '#94a3b8' }}>{truncateId(r.userId)}</div>
</div>
),
},
{
title: '类型', dataIndex: 'genType', width: 90,
render: (v: string) => {
const cfg = GEN_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => {
const name = getEngineName(r.engineSnapshot as any);
return (
<Tooltip title={r.engineId || '未知引擎'} placement="topLeft">
<Tag icon={<RobotOutlined />} color="cyan">{name || r.engineId || '-'}</Tag>
</Tooltip>
);
},
},
{
title: '附件', key: 'references', width: 110,
render: (_: any, r: GenerationAITaskOut) => {
const refs = r.mediaReferences || [];
if (refs.length === 0) return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
const imgCount = refs.filter((ref) => getReferenceType(ref) === 'image').length;
const vidCount = refs.filter((ref) => getReferenceType(ref) === 'video').length;
const otherCount = refs.length - imgCount - vidCount;
return (
<Space size={4} wrap>
{imgCount > 0 ? <Tag color="purple" icon={<FileImageOutlined />}>{imgCount} </Tag> : null}
{vidCount > 0 ? <Tag color="geekblue" icon={<VideoCameraOutlined />}>{vidCount} 视频</Tag> : null}
{otherCount > 0 ? <Tag>{otherCount} 文件</Tag> : null}
</Space>
);
},
},
{
title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => {
if (r.status !== 'completed') {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
}
if (r.genType === 'video' && r.videoUrl) {
return (
<Button
size="small"
icon={<PlayCircleOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handleOpenExternalResource(r.videoUrl, '视频'); }}
>
看视频
</Button>
);
}
if (r.genType === 'image' && r.imageUrl) {
return (
<Button
size="small"
icon={<FileImageOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handleOpenExternalResource(r.imageUrl, '图片'); }}
>
看图片
</Button>
);
}
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
},
},
{
title: '提示词', key: 'prompt', ellipsis: true,
render: (_: any, r: GenerationAITaskOut) => (
<Tooltip title={r.originalPrompt} placement="topLeft">
<Typography.Text style={{ fontSize: 12, color: '#475569' }} ellipsis>
{r.originalPrompt || '-'}
</Typography.Text>
</Tooltip>
),
},
{
title: '参数', key: 'params', width: 180,
render: (_: any, r: GenerationAITaskOut) => (
r.genType === 'video' ? (
r.duration || r.aspectRatio || r.resolution ? (
<Space size={4} wrap>
{r.duration ? <Tag>{r.duration}s</Tag> : null}
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
</Space>
) : <Tag color="default">无参数</Tag>
) : (
r.imageSize || r.imageProportion || r.imagePx ? (
<Space size={4} wrap>
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
</Space>
) : <Tag color="default">无参数</Tag>
)
),
},
{
title: '积分', key: 'credits', width: 130,
render: (_: any, r: GenerationAITaskOut) => (
<div style={{ fontSize: 12 }}>
<div style={{ color: '#6366f1' }}>: {r.creditsCost || 0}</div>
{r.textCreditsCost > 0 ? <div style={{ color: '#f59e0b' }}>文字: {r.textCreditsCost}</div> : null}
</div>
),
},
{
title: '状态', dataIndex: 'status', width: 100,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '阶段', dataIndex: 'pipelineStage', width: 120,
render: (v: string) => <Tag color="blue">{PIPELINE_STAGE_MAP[v] || v || '-'}</Tag>,
},
{
title: '时间', key: 'time', width: 170,
render: (_: any, r: GenerationAITaskOut) => (
<div style={{ fontSize: 12, color: '#94a3b8' }}>
<div>{safeDate(r.createdAt)}</div>
{r.generatedAt ? <div style={{ color: '#10b981' }}>生成: {safeDate(r.generatedAt)}</div> : null}
</div>
),
},
{
title: '操作', key: 'action', width: 90, fixed: 'right' as const,
render: (_: any, r: GenerationAITaskOut) => (
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
详情
</Button>
),
},
], [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 <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
}
if (resourceState.image === 'invalid') {
return <MediaPlaceholder text={getMediaInvalidText(preview.imageUrl, '图片')} minHeight={260} />;
}
const src = apiUrl(preview.imageUrl);
return (
<div
style={{
position: 'relative',
width: '100%',
minHeight: 260,
borderRadius: 12,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
key={`${preview.id}-${src}`}
src={src}
alt="生成图片"
onLoad={() => 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' ? (
<MediaPlaceholder text="图片加载检测中..." minHeight={260} />
) : null}
</div>
);
};
const renderVideoMask = () => {
if (!preview || videoPlaying || resourceState.video === 'invalid') return null;
const canPlay = !!preview.videoUrl;
const playButton = canPlay ? (
<Button
type="primary"
shape="circle"
size="large"
icon={<PlayCircleOutlined />}
onClick={handlePlayVideo}
style={{ boxShadow: '0 8px 20px rgba(15,23,42,0.25)' }}
/>
) : null;
if (!preview.videoCoverUrl) {
return (
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: 12,
overflow: 'hidden',
background: '#f8f9fc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<MediaPlaceholder text="此视频无封面" minHeight={340} action={playButton} />
</div>
);
}
if (resourceState.videoCover === 'invalid') {
return <MediaPlaceholder text={getMediaInvalidText(preview.videoCoverUrl, '视频封面')} minHeight={340} action={playButton} />;
}
const coverSrc = apiUrl(preview.videoCoverUrl);
return (
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: 12,
overflow: 'hidden',
background: '#f8f9fc',
border: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
key={`${preview.id}-cover-${coverSrc}`}
src={coverSrc}
alt="视频封面"
onLoad={() => patchResourceState({ videoCover: 'valid' })}
onError={() => patchResourceState({ videoCover: 'invalid' })}
style={{
display: resourceState.videoCover === 'valid' ? 'block' : 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
background: '#000',
}}
/>
{resourceState.videoCover === 'checking' ? (
<MediaPlaceholder text="视频封面加载检测中..." minHeight={340} action={playButton} />
) : null}
{resourceState.videoCover === 'valid' ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(15,23,42,0.18)',
}}
>
{playButton}
</div>
) : null}
</div>
);
};
const renderResultVideo = () => {
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
}
if (resourceState.video === 'invalid') {
return <MediaPlaceholder text={getMediaInvalidText(preview.videoUrl, '视频')} minHeight={340} />;
}
const videoSrc = apiUrl(preview.videoUrl);
return (
<div
style={{
position: 'relative',
width: '100%',
height: 340,
borderRadius: 12,
background: '#000',
overflow: 'hidden',
border: '1px solid #e2e8f0',
}}
>
<video
key={`${preview.id}-${videoSrc}`}
ref={videoRef}
src={videoSrc}
preload="metadata"
controls={videoPlaying}
onLoadedMetadata={() => patchResourceState({ video: 'valid' })}
onCanPlay={() => patchResourceState({ video: 'valid' })}
onPlaying={() => {
setVideoPlaying(true);
patchResourceState({ video: 'valid' });
}}
onPause={() => setVideoPlaying(false)}
onEnded={() => setVideoPlaying(false)}
onError={() => {
setVideoPlaying(false);
patchResourceState({ video: 'invalid' });
}}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
background: '#000',
opacity: videoPlaying ? 1 : 0,
pointerEvents: videoPlaying ? 'auto' : 'none',
}}
/>
{renderVideoMask()}
</div>
);
};
const renderReferenceTile = (ref: GenerationAIMediaReference, index: number) => {
const refUrl = getReferenceUrl(ref);
const refType = getReferenceType(ref);
const refKey = getReferenceKey(ref, index);
const state = resourceState.references[refKey] || getInitialResourceState(refUrl);
const isImage = refType === 'image';
const isVideo = refType === 'video';
const title = typeof ref.name === 'string' && ref.name ? ref.name : `参考素材 ${index + 1}`;
if (!refUrl) {
return (
<div key={refKey} style={{ width: 94 }}>
<div style={{ width: 94, height: 94 }}>
<MediaPlaceholder text="无链接" compact />
</div>
<Typography.Text ellipsis style={{ display: 'block', marginTop: 4, fontSize: 11, color: '#94a3b8' }}>{title}</Typography.Text>
</div>
);
}
if (state === 'invalid') {
return (
<div key={refKey} style={{ width: 94 }}>
<div style={{ width: 94, height: 94 }}>
<MediaPlaceholder text={isBlobUrl(refUrl) ? '本地临时素材已失效' : '素材不可访问'} compact />
</div>
<Tooltip title={title}>
<Typography.Text ellipsis style={{ display: 'block', marginTop: 4, fontSize: 11, color: '#94a3b8' }}>{title}</Typography.Text>
</Tooltip>
</div>
);
}
const src = apiUrl(refUrl);
return (
<div key={refKey} style={{ width: 94 }}>
<div
role="button"
tabIndex={0}
title="点击新页面查看素材"
onClick={() => handleOpenExternalResource(refUrl, isVideo ? '视频素材' : isImage ? '图片素材' : '素材')}
onKeyDown={(e) => {
if (e.key === 'Enter') handleOpenExternalResource(refUrl, isVideo ? '视频素材' : isImage ? '图片素材' : '素材');
}}
style={{
width: 94,
height: 94,
borderRadius: 10,
overflow: 'hidden',
border: '1px solid #e2e8f0',
background: '#f8f9fc',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
{isImage ? (
<img
src={src}
alt={title}
onLoad={() => patchReferenceState(refKey, 'valid')}
onError={() => patchReferenceState(refKey, 'invalid')}
style={{
display: state === 'valid' ? 'block' : 'none',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : null}
{isVideo ? (
<>
<video
src={src}
preload="metadata"
onLoadedMetadata={() => patchReferenceState(refKey, 'valid')}
onError={() => patchReferenceState(refKey, 'invalid')}
style={{ display: 'none' }}
/>
<div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}>
<PlayCircleOutlined style={{ fontSize: 18 }} />
<div>视频素材</div>
<div style={{ fontSize: 10, color: '#94a3b8', marginTop: 2 }}>点击查看</div>
</div>
</>
) : null}
{!isImage && !isVideo ? (
<div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}>
文件素材
<div style={{ fontSize: 10, color: '#94a3b8', marginTop: 2 }}>点击查看</div>
</div>
) : null}
{state === 'checking' && isImage ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f8f9fc',
}}
>
<LoadingOutlined style={{ color: '#6366f1' }} />
</div>
) : null}
</div>
<Tooltip title={title}>
<Typography.Text ellipsis style={{ display: 'block', marginTop: 4, fontSize: 11, color: '#94a3b8' }}>{title}</Typography.Text>
</Tooltip>
</div>
);
};
const renderReferences = () => {
if (!preview?.mediaReferences || preview.mediaReferences.length === 0) return null;
return (
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>参考内容</Typography.Text>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{preview.mediaReferences.map((ref, index) => renderReferenceTile(ref, index))}
</div>
</div>
);
};
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}>创作记录管理</Typography.Text>
<Tag color="purple">{total} 条记录</Tag>
</Space>
<Space>
<Select
allowClear
placeholder="状态筛选"
value={filterStatus || undefined}
style={{ width: 140 }}
onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
options={[
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="类型筛选"
value={filterGenType || undefined}
style={{ width: 120 }}
onChange={(v) => { setFilterGenType(v || ''); setPage(1); }}
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Select
allowClear
showSearch
placeholder="引擎筛选"
value={filterEngineId || undefined}
style={{ width: 180 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
optionFilterProp="label"
options={Array.from(
new Map(
records
.filter((r) => r.engineId)
.map((r) => [r.engineId, {
value: r.engineId,
label: getEngineName(r.engineSnapshot as any) || r.engineId,
}]),
).values(),
)}
/>
<RangePicker
value={createdRange}
onChange={(dates) => { setCreatedRange(dates); }}
placeholder={['开始日期', '结束日期']}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 150 }}
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
<Input
placeholder="用户名搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 150 }}
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
搜索
</Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
重置
</Button>
</Space>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1650 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `共 ${t} 条`,
onChange: (p) => setPage(p),
}}
/>
</Card>
<Modal
title={(
<Space>
{preview?.genType === 'video' ? <VideoCameraOutlined /> : <FileImageOutlined />}
<span>创作记录详情</span>
</Space>
)}
open={!!preview}
onCancel={handleClosePreview}
footer={null}
width={900}
destroyOnClose
>
{preview ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
{preview.pipelineStage ? <Tag color="blue">{PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage}</Tag> : null}
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
</div>
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
<InfoItem label="用户名称" value={preview.userName || '未知用户'} />
<InfoItem label="用户ID" value={preview.userId || '-'} />
<InfoItem label="任务ID" value={preview.id} />
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>原始提示词</Typography.Text>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc', lineHeight: 1.6 }}>
{preview.originalPrompt || '-'}
</div>
</div>
{preview.genType === 'video' ? (
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
<InfoItem label="时长" value={preview.duration ? `${preview.duration}秒` : '-'} />
<InfoItem label="画面比例" value={preview.aspectRatio || '-'} />
<InfoItem label="分辨率" value={preview.resolution || '-'} />
</div>
) : (
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
<InfoItem label="图片档位" value={preview.imageSize || '-'} />
<InfoItem label="图片比例" value={preview.imageProportion || '-'} />
<InfoItem label="像素尺寸" value={preview.imagePx || '-'} />
</div>
)}
{preview.engineSnapshot ? (
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 8 }}>引擎快照</Typography.Text>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<InfoItem label="引擎名称" value={preview.engineSnapshot.name || '-'} />
<InfoItem label="服务商" value={preview.engineSnapshot.provider || '-'} />
<InfoItem label="模型" value={preview.engineSnapshot.modelName || '-'} />
<InfoItem label="引擎ID" value={preview.engineSnapshot.id || preview.engineId || '-'} />
</div>
</div>
) : null}
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
<InfoItem label="总积分" value={<Typography.Text strong style={{ color: '#6366f1' }}>{preview.creditsCost || 0}</Typography.Text>} />
<InfoItem label="文字积分" value={`${preview.textCreditsCost || 0} (${preview.textTokensUsed || 0} tokens)`} />
<InfoItem label="图片 tokens" value={preview.imageTokensUsed ?? 0} />
<InfoItem label="视频 tokens" value={preview.videoTokensUsed ?? 0} />
</div>
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
<InfoItem label="第三方任务ID" value={preview.providerTaskId || preview.seedanceTaskId || '-'} />
<InfoItem label="轮询次数" value={preview.pollCount ?? 0} />
<InfoItem label="重试次数" value={preview.retryCount ?? 0} />
</div>
{renderReferences()}
{/* 附件快速查看区 */}
{(preview.mediaReferences && preview.mediaReferences.length > 0) ? (
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 8 }}>
附件快速查看(点击新窗口打开)
</Typography.Text>
<Space size={8} wrap>
{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 (
<Button
key={idx}
size="small"
icon={refType === 'video' ? <PlayCircleOutlined /> : <LinkOutlined />}
onClick={() => handlePreviewResource(refUrl, refType === 'video' ? 'video' : 'image', title)}
>
{title}
</Button>
);
})}
</Space>
</div>
) : null}
{preview.status === 'completed' ? (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
</Typography.Text>
{preview.genType === 'video' && preview.videoUrl ? (
<Button
size="small"
type="link"
icon={<PlayCircleOutlined />}
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
style={{ padding: 0 }}
>
弹窗播放
</Button>
) : null}
{preview.genType === 'image' && preview.imageUrl ? (
<Button
size="small"
type="link"
icon={<FileImageOutlined />}
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
style={{ padding: 0 }}
>
弹窗查看
</Button>
) : null}
</div>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
</div>
) : null}
{preview.status === 'failed' && preview.errorMessage ? (
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
</div>
) : null}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#94a3b8', flexWrap: 'wrap' }}>
<span>创建: {safeDate(preview.createdAt)}</span>
<span>生成: {safeDate(preview.generatedAt)}</span>
</div>
</div>
) : (
<Empty description="暂无详情" />
)}
</Modal>
{/* 资源预览弹窗(附件 / 最终结果) */}
<Modal
title={resourcePreview?.title || '资源预览'}
open={!!resourcePreview}
onCancel={handleCloseResourcePreview}
footer={null}
width={resourcePreview?.type === 'video' ? 800 : 600}
destroyOnHidden
>
{resourcePreview ? (
resourcePreview.type === 'video' ? (
<video
key={resourcePreview.url}
src={resourcePreview.url}
controls
autoPlay
style={{ width: '100%', maxHeight: 600, background: '#000', borderRadius: 8 }}
/>
) : (
<img
src={resourcePreview.url}
alt={resourcePreview.title}
style={{ width: '100%', maxHeight: 600, objectFit: 'contain', borderRadius: 8 }}
/>
)
) : null}
</Modal>
</div>
);
};
export default AdminGenerationAiRecords;