创作记录-管理后台完成
This commit is contained in:
@@ -19,6 +19,7 @@ import AdminMenuConfig from './pages/AdminMenuConfig';
|
|||||||
import AdminRechargePackages from './pages/AdminRechargePackages';
|
import AdminRechargePackages from './pages/AdminRechargePackages';
|
||||||
import AdminOperationLogs from './pages/AdminOperationLogs';
|
import AdminOperationLogs from './pages/AdminOperationLogs';
|
||||||
import AdminGenerationRecords from './pages/AdminGenerationRecords';
|
import AdminGenerationRecords from './pages/AdminGenerationRecords';
|
||||||
|
import AdminGenerationAiRecords from './pages/AdminGenerationAiRecords';
|
||||||
import { useAdminStore } from './store';
|
import { useAdminStore } from './store';
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -78,6 +79,7 @@ const App = () => {
|
|||||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||||
<Route path="generation-records" element={<AdminGenerationRecords />} />
|
<Route path="generation-records" element={<AdminGenerationRecords />} />
|
||||||
|
<Route path="generation-ai" element={<AdminGenerationAiRecords />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { api, setToken, clearToken } from './client';
|
|||||||
import type {
|
import type {
|
||||||
User, CreditRecord, Project, GenerationRecord, GenerationParams,
|
User, CreditRecord, Project, GenerationRecord, GenerationParams,
|
||||||
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||||
GenerationAiEnginesResponse,
|
GenerationAiEnginesResponse, GenerationAITaskListOut, GenerationAITaskQueryParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────
|
||||||
@@ -309,3 +309,16 @@ export async function adminGenerateVideo(
|
|||||||
export async function getGenerationAiEngines(): Promise<GenerationAiEnginesResponse> {
|
export async function getGenerationAiEngines(): Promise<GenerationAiEnginesResponse> {
|
||||||
return api.get<GenerationAiEnginesResponse>(`/generation-ai/engines`);
|
return api.get<GenerationAiEnginesResponse>(`/generation-ai/engines`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryParams): Promise<GenerationAITaskListOut> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.genType) q.set('gen_type', params.genType);
|
||||||
|
if (params?.status) q.set('status', params.status);
|
||||||
|
if (params?.page) q.set('page', String(params.page));
|
||||||
|
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||||
|
if (params?.userId) q.set('user_id', params.userId);
|
||||||
|
if (params?.userName) q.set('user_name', params.userName);
|
||||||
|
const qs = q.toString();
|
||||||
|
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,898 @@
|
|||||||
|
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';
|
||||||
|
// const RAW_API_BASE = 'http://ceshi.apiforeign.minzhong.cn';
|
||||||
|
// 资源 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<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> = {
|
||||||
|
queued: '已入队',
|
||||||
|
creating_provider_task: '创建任务中',
|
||||||
|
waiting_remote: '等待生成',
|
||||||
|
result_ready: '结果就绪',
|
||||||
|
downloading: '下载中',
|
||||||
|
done: '完成',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 [inputUserId, setInputUserId] = useState<string>('');
|
||||||
|
const [inputUserName, setInputUserName] = useState<string>('');
|
||||||
|
const [queryUserId, setQueryUserId] = useState<string>('');
|
||||||
|
const [queryUserName, setQueryUserName] = useState<string>('');
|
||||||
|
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 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<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());
|
||||||
|
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 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: '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 <MediaPlaceholder text="此视频无封面" minHeight={340} action={playButton} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
style={{
|
||||||
|
width: 94,
|
||||||
|
height: 94,
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
background: '#f8f9fc',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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' }}>
|
||||||
|
<VideoCameraOutlined />
|
||||||
|
<div>视频素材</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isImage && !isVideo ? (
|
||||||
|
<div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}>文件素材</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 style={{ padding: 24 }}>
|
||||||
|
<Card
|
||||||
|
title={(
|
||||||
|
<Space>
|
||||||
|
<FileImageOutlined />
|
||||||
|
<span>创作记录管理</span>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
extra={(
|
||||||
|
<Space wrap>
|
||||||
|
<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: '视频' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="用户ID"
|
||||||
|
value={inputUserId}
|
||||||
|
onChange={(e) => setInputUserId(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="用户名"
|
||||||
|
value={inputUserName}
|
||||||
|
onChange={(e) => setInputUserName(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
bordered={false}
|
||||||
|
style={{ borderRadius: 16 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns as any}
|
||||||
|
dataSource={records}
|
||||||
|
scroll={{ x: 1180 }}
|
||||||
|
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.status === 'completed' ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
||||||
|
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
||||||
|
</Typography.Text>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminGenerationAiRecords;
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
MenuOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||||
UserOutlined,
|
HomeOutlined, PlayCircleOutlined, WalletOutlined, RobotOutlined,
|
||||||
RobotOutlined,
|
SettingOutlined, BellOutlined, UserOutlined, AppstoreOutlined,
|
||||||
SettingOutlined,
|
FileTextOutlined, StarOutlined, HeartOutlined, CameraOutlined,
|
||||||
BellOutlined,
|
DashboardOutlined, CalculatorOutlined, DollarOutlined, GiftOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined, FireOutlined, CloudOutlined, SmileOutlined,
|
||||||
LogoutOutlined,
|
TrophyOutlined, RocketOutlined, BulbOutlined, CodeOutlined,
|
||||||
LockOutlined,
|
PictureOutlined, VideoCameraOutlined, AudioOutlined,
|
||||||
WalletOutlined,
|
MailOutlined, PhoneOutlined, GlobalOutlined, ShoppingCartOutlined,
|
||||||
CalculatorOutlined,
|
TeamOutlined, BarChartOutlined, PieChartOutlined, LineChartOutlined,
|
||||||
DollarOutlined,
|
SecurityScanOutlined, ApiOutlined, DatabaseOutlined, CloudServerOutlined,
|
||||||
AppstoreOutlined,
|
LockOutlined, LogoutOutlined
|
||||||
PlayCircleOutlined,
|
|
||||||
GiftOutlined,
|
|
||||||
HomeOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
HeartOutlined,
|
|
||||||
CameraOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
HistoryOutlined,
|
|
||||||
VideoCameraOutlined,
|
|
||||||
PictureOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||||
import { useAdminStore } from '../store';
|
import { useAdminStore } from '../store';
|
||||||
@@ -31,26 +21,28 @@ import { getMenuConfigs, adminChangePassword } from '../api';
|
|||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
DashboardOutlined: <DashboardOutlined />,
|
HomeOutlined: <HomeOutlined />, PlayCircleOutlined: <PlayCircleOutlined />,
|
||||||
UserOutlined: <UserOutlined />,
|
WalletOutlined: <WalletOutlined />, RobotOutlined: <RobotOutlined />,
|
||||||
RobotOutlined: <RobotOutlined />,
|
SettingOutlined: <SettingOutlined />, BellOutlined: <BellOutlined />,
|
||||||
SettingOutlined: <SettingOutlined />,
|
UserOutlined: <UserOutlined />, AppstoreOutlined: <AppstoreOutlined />,
|
||||||
BellOutlined: <BellOutlined />,
|
FileTextOutlined: <FileTextOutlined />, StarOutlined: <StarOutlined />,
|
||||||
LogoutOutlined: <LogoutOutlined />,
|
HeartOutlined: <HeartOutlined />, CameraOutlined: <CameraOutlined />,
|
||||||
WalletOutlined: <WalletOutlined />,
|
DashboardOutlined: <DashboardOutlined />, CalculatorOutlined: <CalculatorOutlined />,
|
||||||
CalculatorOutlined: <CalculatorOutlined />,
|
DollarOutlined: <DollarOutlined />, GiftOutlined: <GiftOutlined />,
|
||||||
DollarOutlined: <DollarOutlined />,
|
ThunderboltOutlined: <ThunderboltOutlined />, FireOutlined: <FireOutlined />,
|
||||||
AppstoreOutlined: <AppstoreOutlined />,
|
CloudOutlined: <CloudOutlined />, SmileOutlined: <SmileOutlined />,
|
||||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
TrophyOutlined: <TrophyOutlined />, RocketOutlined: <RocketOutlined />,
|
||||||
GiftOutlined: <GiftOutlined />,
|
BulbOutlined: <BulbOutlined />, CodeOutlined: <CodeOutlined />,
|
||||||
HomeOutlined: <HomeOutlined />,
|
PictureOutlined: <PictureOutlined />, VideoCameraOutlined: <VideoCameraOutlined />,
|
||||||
StarOutlined: <StarOutlined />,
|
AudioOutlined: <AudioOutlined />, MailOutlined: <MailOutlined />,
|
||||||
HeartOutlined: <HeartOutlined />,
|
PhoneOutlined: <PhoneOutlined />, GlobalOutlined: <GlobalOutlined />,
|
||||||
CameraOutlined: <CameraOutlined />,
|
ShoppingCartOutlined: <ShoppingCartOutlined />, TeamOutlined: <TeamOutlined />,
|
||||||
FileTextOutlined: <FileTextOutlined />,
|
BarChartOutlined: <BarChartOutlined />, PieChartOutlined: <PieChartOutlined />,
|
||||||
HistoryOutlined: <HistoryOutlined />,
|
LineChartOutlined: <LineChartOutlined />, SecurityScanOutlined: <SecurityScanOutlined />,
|
||||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
ApiOutlined: <ApiOutlined />, DatabaseOutlined: <DatabaseOutlined />,
|
||||||
PictureOutlined: <PictureOutlined />,
|
CloudServerOutlined: <CloudServerOutlined />, MenuOutlined: <MenuOutlined />,
|
||||||
|
PlusOutlined: <PlusOutlined />, EditOutlined: <EditOutlined />, DeleteOutlined: <DeleteOutlined />,
|
||||||
|
LockOutlined: <LockOutlined />, LogoutOutlined: <LogoutOutlined />
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminLayout: React.FC = () => {
|
const AdminLayout: React.FC = () => {
|
||||||
|
|||||||
@@ -225,3 +225,87 @@ export interface AdminGenerationRecord {
|
|||||||
imageProportion?: string,
|
imageProportion?: string,
|
||||||
imagePx?: string,
|
imagePx?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
|
||||||
|
|
||||||
|
export interface GenerationAIMediaReference {
|
||||||
|
url?: string;
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationAIEngineSnapshot {
|
||||||
|
engineType?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
provider?: string;
|
||||||
|
modelName?: string;
|
||||||
|
supportedModels?: string[];
|
||||||
|
defaultSize?: string;
|
||||||
|
selectedSize?: string;
|
||||||
|
selectedProportion?: string;
|
||||||
|
selectedPx?: string;
|
||||||
|
selectedRatio?: string;
|
||||||
|
selectedResolution?: string;
|
||||||
|
selectedDuration?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationAITaskOut {
|
||||||
|
id: string;
|
||||||
|
sourceType?: 'chat_task' | string;
|
||||||
|
userId?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
genType: GenerationAiGenType | string;
|
||||||
|
generationMode?: string | null;
|
||||||
|
pipelineStage?: string | null;
|
||||||
|
status: GenerationAITaskStatus;
|
||||||
|
originalPrompt: string;
|
||||||
|
optimizedPrompt?: string | null;
|
||||||
|
duration?: number | null;
|
||||||
|
aspectRatio?: string | null;
|
||||||
|
resolution?: string | null;
|
||||||
|
imageSize?: string | null;
|
||||||
|
imageProportion?: string | null;
|
||||||
|
imagePx?: string | null;
|
||||||
|
mediaReferences?: GenerationAIMediaReference[] | null;
|
||||||
|
providerTaskId?: string | null;
|
||||||
|
seedanceTaskId?: string | null;
|
||||||
|
remoteResultUrl?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
|
engineId?: string | null;
|
||||||
|
engineSnapshot?: GenerationAIEngineSnapshot | null;
|
||||||
|
creditsCost: number;
|
||||||
|
textCreditsCost: number;
|
||||||
|
textTokensUsed: number;
|
||||||
|
imageTokensUsed: number;
|
||||||
|
videoTokensUsed: number;
|
||||||
|
retryCount: number;
|
||||||
|
pollCount: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
generatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationAITaskListOut {
|
||||||
|
total: number;
|
||||||
|
items: GenerationAITaskOut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationAITaskQueryParams {
|
||||||
|
genType?: GenerationAiGenType | string;
|
||||||
|
status?: GenerationAITaskStatus;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,26 +199,47 @@ async def list_tasks(
|
|||||||
description="每页返回数量,范围 1~100",
|
description="每页返回数量,范围 1~100",
|
||||||
examples=[20],
|
examples=[20],
|
||||||
),
|
),
|
||||||
|
user_id: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="查询相关用户ID的对应记录[管理后台]",
|
||||||
|
examples=["0019e0a448a23114888"],
|
||||||
|
),
|
||||||
|
user_name: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="查询相关用户名的对应记录[管理后台]",
|
||||||
|
examples=["demo"],
|
||||||
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
is_admin = False
|
||||||
|
if current_user.user_type == 'admin':
|
||||||
|
is_admin = True
|
||||||
|
else:
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
total, items = await list_async_generation_tasks(
|
total, items = await list_async_generation_tasks(
|
||||||
db,
|
db,
|
||||||
current_user.id,
|
user_id,
|
||||||
|
user_name,
|
||||||
gen_type,
|
gen_type,
|
||||||
status,
|
status,
|
||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
|
is_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ====================== 在这里加排序(最新在前)======================
|
# ====================== 在这里加排序(最新在前)======================
|
||||||
|
if not is_admin:
|
||||||
# 按 created_at 降序(没有则用 id 降序)
|
# 按 created_at 降序(没有则用 id 降序)
|
||||||
items_sorted = sorted(
|
items_sorted = sorted(
|
||||||
items,
|
items,
|
||||||
key=lambda x: x.created_at if x.created_at is not None else x.id,
|
key=lambda x: x.created_at if x.created_at is not None else x.id,
|
||||||
reverse=False # 降序
|
reverse=False # 升序
|
||||||
)
|
)
|
||||||
return GenerationAITaskListOut(total=total, items=[record_to_out(i) for i in items_sorted])
|
else:
|
||||||
|
items_sorted = items
|
||||||
|
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin) for i in items_sorted])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ class GenerationAITaskOut(BaseModel):
|
|||||||
"example": {
|
"example": {
|
||||||
"id": "0019e0a44895b6d837d",
|
"id": "0019e0a44895b6d837d",
|
||||||
"source_type": "chat_task",
|
"source_type": "chat_task",
|
||||||
|
"user_id": None,
|
||||||
|
"user_name": None,
|
||||||
"project_id": None,
|
"project_id": None,
|
||||||
"gen_type": "image",
|
"gen_type": "image",
|
||||||
"generation_mode": "chatapi_async",
|
"generation_mode": "chatapi_async",
|
||||||
@@ -302,6 +304,14 @@ class GenerationAITaskOut(BaseModel):
|
|||||||
"chat_task",
|
"chat_task",
|
||||||
description="历史记录来源。ChatGenerationTask 新任务历史固定为 chat_task",
|
description="历史记录来源。ChatGenerationTask 新任务历史固定为 chat_task",
|
||||||
)
|
)
|
||||||
|
user_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="用户ID,管理后台调用存在对应值,通常为 null",
|
||||||
|
)
|
||||||
|
user_name: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="用户名称,管理后台调用存在对应值,通常为 null",
|
||||||
|
)
|
||||||
project_id: str | None = Field(
|
project_id: str | None = Field(
|
||||||
None,
|
None,
|
||||||
description="项目ID。当前 /generation-ai 任务不绑定项目,通常为 null",
|
description="项目ID。当前 /generation-ai 任务不绑定项目,通常为 null",
|
||||||
@@ -374,6 +384,8 @@ class GenerationAITaskListOut(BaseModel):
|
|||||||
{
|
{
|
||||||
"id": "0019e0a44895b6d837d",
|
"id": "0019e0a44895b6d837d",
|
||||||
"source_type": "chat_task",
|
"source_type": "chat_task",
|
||||||
|
"user_id": None,
|
||||||
|
"user_name": None,
|
||||||
"project_id": None,
|
"project_id": None,
|
||||||
"gen_type": "image",
|
"gen_type": "image",
|
||||||
"generation_mode": "chatapi_async",
|
"generation_mode": "chatapi_async",
|
||||||
|
|||||||
@@ -311,12 +311,14 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
def record_to_out(task: ChatGenerationTask) -> GenerationAITaskOut:
|
def record_to_out(task: ChatGenerationTask, is_admin: bool = False) -> GenerationAITaskOut:
|
||||||
refs = _parse_json(task.media_references)
|
refs = _parse_json(task.media_references)
|
||||||
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
|
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
|
||||||
return GenerationAITaskOut(
|
return GenerationAITaskOut(
|
||||||
id=task.id,
|
id=task.id,
|
||||||
# project_id=None,
|
user_id=task.user_id if is_admin else None,
|
||||||
|
user_name=getattr(task, "username", None) if is_admin else None,
|
||||||
|
project_id=None,
|
||||||
gen_type=task.gen_type,
|
gen_type=task.gen_type,
|
||||||
generation_mode=task.generation_mode,
|
generation_mode=task.generation_mode,
|
||||||
pipeline_stage=task.pipeline_stage,
|
pipeline_stage=task.pipeline_stage,
|
||||||
@@ -376,28 +378,55 @@ def engine_snapshot_out(snapshot: dict) -> dict:
|
|||||||
|
|
||||||
async def list_async_generation_tasks(
|
async def list_async_generation_tasks(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: str,
|
user_id: str | None,
|
||||||
|
user_name: str | None,
|
||||||
gen_type: str | None,
|
gen_type: str | None,
|
||||||
status: str | None,
|
status: str | None,
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
|
is_admin: bool = False,
|
||||||
):
|
):
|
||||||
query = select(ChatGenerationTask).where(
|
if is_admin:
|
||||||
ChatGenerationTask.user_id == user_id,
|
query = (
|
||||||
|
select(ChatGenerationTask, User.username)
|
||||||
|
.join(User, ChatGenerationTask.user_id == User.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_name:
|
||||||
|
query = query.where(User.username.like(f"%{user_name}%"))
|
||||||
|
else:
|
||||||
|
query = select(ChatGenerationTask)
|
||||||
|
|
||||||
|
query = query.where(
|
||||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
ChatGenerationTask.generation_mode == "chatapi_async",
|
||||||
ChatGenerationTask.deleted_at.is_(None),
|
ChatGenerationTask.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.where(ChatGenerationTask.user_id == user_id)
|
||||||
|
|
||||||
if gen_type:
|
if gen_type:
|
||||||
query = query.where(ChatGenerationTask.gen_type == gen_type)
|
query = query.where(ChatGenerationTask.gen_type == gen_type)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query = query.where(ChatGenerationTask.status == status)
|
query = query.where(ChatGenerationTask.status == status)
|
||||||
|
|
||||||
count_query = select(func.count()).select_from(query.subquery())
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
total = (await db.execute(count_query)).scalar_one()
|
total = (await db.execute(count_query)).scalar_one()
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
query.order_by(ChatGenerationTask.created_at.desc())
|
query.order_by(ChatGenerationTask.created_at.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
tasks = []
|
||||||
|
for task, username in result.all():
|
||||||
|
task.username = username
|
||||||
|
tasks.append(task)
|
||||||
|
return total, tasks
|
||||||
|
|
||||||
return total, list(result.scalars().all())
|
return total, list(result.scalars().all())
|
||||||
|
|
||||||
def _normalize_history_gen_type(gen_type: str | None) -> str:
|
def _normalize_history_gen_type(gen_type: str | None) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user