1077 lines
40 KiB
TypeScript
1077 lines
40 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 {
|
|
PlayCircleOutlined,
|
|
EyeOutlined,
|
|
ClockCircleOutlined,
|
|
CheckCircleOutlined,
|
|
LoadingOutlined,
|
|
CloseCircleOutlined,
|
|
SearchOutlined,
|
|
VideoCameraOutlined,
|
|
FileImageOutlined,
|
|
} from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
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<string, ResourceState>;
|
|
}
|
|
|
|
const EMPTY_RESOURCE_STATE: PreviewResourceState = {
|
|
image: 'empty',
|
|
video: 'empty',
|
|
videoCover: 'empty',
|
|
references: {},
|
|
};
|
|
|
|
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',
|
|
}}
|
|
>
|
|
<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 AdminGenerationRecords: React.FC = () => {
|
|
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize] = useState(20);
|
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
|
const [filterUserId, setFilterUserId] = useState<string>('');
|
|
const [filterEngineId, setFilterEngineId] = useState<string>('');
|
|
const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>('');
|
|
const [engineOptions, setEngineOptions] = useState<Array<{ value: string; label: string }>>([]);
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
|
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
|
const [videoPlaying, setVideoPlaying] = useState(false);
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
|
|
// 时间筛选(默认当天)
|
|
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
|
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
|
|
|
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',
|
|
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
|
|
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
|
|
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, queryCreatedRange]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load, reloadKey]);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
getImageEngines({ includeDeleted: true }),
|
|
getVideoEngines({ includeDeleted: true }),
|
|
])
|
|
.then(([imageEngines, videoEngines]) => {
|
|
const items = [...(imageEngines || []), ...(videoEngines || [])];
|
|
const seen = new Set<string>();
|
|
setEngineOptions(items.reduce<Array<{ value: string; label: string }>>((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<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);
|
|
setQueryCreatedRange(createdRange);
|
|
setReloadKey((v) => v + 1);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setFilterStatus('');
|
|
setFilterUserId('');
|
|
setFilterEngineId('');
|
|
setFilterIncludeMedia('');
|
|
const defaultRange = [todayStart(), todayEnd()];
|
|
setCreatedRange(defaultRange);
|
|
setQueryCreatedRange(defaultRange);
|
|
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<PreviewResourceState>) => {
|
|
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) => (
|
|
<div>
|
|
<Typography.Text strong style={{ fontSize: 13 }}>{r.username || '未知用户'}</Typography.Text>
|
|
<div style={{ fontSize: 11, color: '#94a3b8' }}>{truncateId(r.userId)}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
|
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>,
|
|
},
|
|
{
|
|
title: '行业', dataIndex: 'industry', width: 100, ellipsis: true,
|
|
render: (v: string) => <Tag color="cyan">{v || '-'}</Tag>,
|
|
},
|
|
{
|
|
title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
|
|
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: 'prompt', ellipsis: true,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<Tooltip title={r.originalPrompt} placement="topLeft">
|
|
<Typography.Text style={{ fontSize: 12, color: '#475569' }} ellipsis>
|
|
{r.originalPrompt || '-'}
|
|
</Typography.Text>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
title: '参数', key: 'params', width: 160,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
r.genType === 'video' ? (
|
|
<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}
|
|
{!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default">待配置</Tag> : null}
|
|
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
|
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
|
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
|
</Tag>
|
|
</Space>
|
|
) : (
|
|
<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}
|
|
{!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default">待配置</Tag> : null}
|
|
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
|
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
|
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
|
</Tag>
|
|
</Space>
|
|
)
|
|
),
|
|
},
|
|
{
|
|
title: '积分', key: 'credits', width: 120,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<div style={{ fontSize: 12 }}>
|
|
{r.textCreditsCost > 0 ? (
|
|
<div style={{ color: '#f59e0b' }}>文字: {r.textCreditsCost}</div>
|
|
) : null}
|
|
{r.creditsCost > 0 ? (
|
|
<div style={{ color: '#6366f1' }}>{r.genType === 'video' ? '视频' : '图片'}: {r.creditsCost}</div>
|
|
) : null}
|
|
{r.textCreditsCost === 0 && r.creditsCost === 0 ? (
|
|
<Typography.Text style={{ color: '#94a3b8' }}>0</Typography.Text>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'status', width: 90,
|
|
render: (v: string) => {
|
|
const state = resolveGenerationUiState({ status: v });
|
|
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
|
|
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
|
|
},
|
|
},
|
|
{
|
|
title: '阶段', dataIndex: 'pipelineStage', width: 150,
|
|
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
|
|
},
|
|
{
|
|
title: '时间', key: 'time', width: 150,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<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: AdminGenerationRecord) => (
|
|
<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 ? resolveGenerationUiState(preview) : null;
|
|
|
|
const renderResultImage = () => {
|
|
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) 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
|
|
title="点击新页面查看图片"
|
|
onClick={() => 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',
|
|
}}
|
|
>
|
|
<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' || !resolveGenerationUiState(preview).isSuccess) 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?.references || preview.references.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.references.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>
|
|
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
|
<Typography.Text strong style={{ fontSize: 16 }}>生成记录管理</Typography.Text>
|
|
<Tag color="purple">{total} 条记录</Tag>
|
|
</Space>
|
|
<Space>
|
|
<Select
|
|
placeholder="状态筛选"
|
|
allowClear
|
|
style={{ width: 120 }}
|
|
value={filterStatus || undefined}
|
|
onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
|
|
options={[
|
|
{ value: 'optimizing', label: '优化中' },
|
|
{ value: 'prompt_optimized', label: '待生成' },
|
|
{ value: 'generating', label: '生成中' },
|
|
{ value: 'completed', label: '已完成' },
|
|
{ value: 'failed', label: '失败' },
|
|
]}
|
|
/>
|
|
<Select
|
|
placeholder="引擎筛选"
|
|
allowClear
|
|
showSearch
|
|
optionFilterProp="label"
|
|
style={{ width: 220 }}
|
|
value={filterEngineId || undefined}
|
|
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); }}
|
|
options={engineOptions}
|
|
/>
|
|
<Select
|
|
placeholder="附件状态"
|
|
allowClear
|
|
style={{ width: 130 }}
|
|
value={filterIncludeMedia || undefined}
|
|
onChange={(v) => { setFilterIncludeMedia((v || '') as '' | 'true' | 'false'); setPage(1); }}
|
|
options={[
|
|
{ value: 'true', label: '携带附件' },
|
|
{ value: 'false', label: '不携带附件' },
|
|
]}
|
|
/>
|
|
<Input
|
|
placeholder="用户ID搜索"
|
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
|
style={{ width: 200 }}
|
|
value={filterUserId}
|
|
onChange={(e) => setFilterUserId(e.target.value)}
|
|
onPressEnter={handleSearch}
|
|
allowClear
|
|
/>
|
|
<DatePicker.RangePicker
|
|
value={createdRange}
|
|
onChange={(dates) => {
|
|
if (dates && dates[0] && dates[1]) {
|
|
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
|
|
} else {
|
|
setCreatedRange(dates);
|
|
}
|
|
}}
|
|
placeholder={['开始日期', '结束日期']}
|
|
/>
|
|
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
|
|
搜索
|
|
</Button>
|
|
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
|
|
重置
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns as any}
|
|
dataSource={records}
|
|
rowKey="id"
|
|
loading={loading}
|
|
scroll={{ x: 1120 }}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total,
|
|
onChange: setPage,
|
|
showSizeChanger: false,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Detail modal */}
|
|
<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: 16 }}>
|
|
{/* User & Project info */}
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<div style={{ flex: 1, minWidth: 150, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>用户</Typography.Text>
|
|
<Typography.Text strong>{preview.username || '-'}</Typography.Text>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 150, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>项目</Typography.Text>
|
|
<Typography.Text strong>{preview.projectName || '-'}</Typography.Text>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 150, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>类型 / 状态</Typography.Text>
|
|
<Space size={4} wrap>
|
|
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
|
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
|
|
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
|
|
{preview.videoUpscaleEnabled ? <Tag color="purple">已启用超分</Tag> : null}
|
|
</Space>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Prompts */}
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>原始提示词</Typography.Text>
|
|
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc', border: '1px solid #f0f0f5' }}>
|
|
<Typography.Text style={{ fontSize: 13, color: '#475569', lineHeight: 1.7 }}>{preview.originalPrompt || '-'}</Typography.Text>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>优化后提示词</Typography.Text>
|
|
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(99,102,241,0.02)', border: '1px solid rgba(99,102,241,0.1)' }}>
|
|
<Typography.Text style={{ fontSize: 13, color: '#1a1a2e', lineHeight: 1.7 }}>{preview.optimizedPrompt || '-'}</Typography.Text>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credits */}
|
|
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
|
|
<InfoItem
|
|
label="文字积分"
|
|
value={(
|
|
<>
|
|
<Typography.Text strong style={{ color: '#f59e0b' }}>{preview.textCreditsCost || 0}</Typography.Text>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.textTokensUsed || 0} tokens)</Typography.Text>
|
|
</>
|
|
)}
|
|
/>
|
|
<InfoItem
|
|
label={`${preview.genType === 'image' ? '图片' : '视频'}积分`}
|
|
value={(
|
|
<>
|
|
<Typography.Text strong style={{ color: '#6366f1' }}>{preview.creditsCost || 0}</Typography.Text>
|
|
{preview.genType === 'video' && preview.videoTokensUsed ? (
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.videoTokensUsed} tokens)</Typography.Text>
|
|
) : null}
|
|
{preview.genType === 'image' && preview.imageTokensUsed ? (
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.imageTokensUsed} tokens)</Typography.Text>
|
|
) : null}
|
|
</>
|
|
)}
|
|
/>
|
|
<InfoItem label="总积分" value={(preview.textCreditsCost || 0) + (preview.creditsCost || 0)} />
|
|
</div>
|
|
|
|
{/* Video Params */}
|
|
{preview.genType === 'video' ? (
|
|
preview.duration || preview.aspectRatio || preview.resolution ? (
|
|
<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={{ padding: 12, borderRadius: 10, background: '#f8f9fc', textAlign: 'center' }}>
|
|
<Tag color="default">视频参数待用户配置</Tag>
|
|
</div>
|
|
)
|
|
) : null}
|
|
|
|
{/* Image Params */}
|
|
{preview.genType === 'image' ? (
|
|
preview.imageSize || preview.imageProportion || preview.imagePx ? (
|
|
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc', flexWrap: 'wrap' }}>
|
|
<InfoItem label="尺寸" value={preview.imagePx || '-'} />
|
|
<InfoItem label="比例" value={preview.imageProportion || '-'} />
|
|
<InfoItem label="分辨率" value={preview.imageSize || '-'} />
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc', textAlign: 'center' }}>
|
|
<Tag color="default">图片参数待用户配置</Tag>
|
|
</div>
|
|
)
|
|
) : null}
|
|
|
|
{renderReferences()}
|
|
|
|
{resolveGenerationUiState(preview).isSuccess ? (
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
|
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
|
</Typography.Text>
|
|
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Error message */}
|
|
{resolveGenerationUiState(preview).isFailure && 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}
|
|
|
|
{/* Timestamps */}
|
|
<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>
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminGenerationRecords;
|