From 7ef0450dd105893eaf09906b50434bb456cb39a1 Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Wed, 3 Jun 2026 12:48:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E4=BD=9C=E8=AE=B0=E5=BD=95-=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-admin/src/App.tsx | 2 + video-gen-admin/src/api/index.ts | 15 +- .../src/pages/AdminGenerationAiRecords.tsx | 898 ++++++++++++++++++ video-gen-admin/src/pages/AdminLayout.tsx | 76 +- video-gen-admin/src/types/index.ts | 84 ++ video-gen-api/app/api/v1/generation_ai.py | 37 +- video-gen-api/app/schemas/generation_ai.py | 12 + .../app/services/generation_ai_service.py | 39 +- 8 files changed, 1107 insertions(+), 56 deletions(-) create mode 100644 video-gen-admin/src/pages/AdminGenerationAiRecords.tsx diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 6cfcec59..cbf94893 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -19,6 +19,7 @@ import AdminMenuConfig from './pages/AdminMenuConfig'; import AdminRechargePackages from './pages/AdminRechargePackages'; import AdminOperationLogs from './pages/AdminOperationLogs'; import AdminGenerationRecords from './pages/AdminGenerationRecords'; +import AdminGenerationAiRecords from './pages/AdminGenerationAiRecords'; import { useAdminStore } from './store'; const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { @@ -78,6 +79,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index 30ca0d1b..649dc3cb 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -6,7 +6,7 @@ import { api, setToken, clearToken } from './client'; import type { User, CreditRecord, Project, GenerationRecord, GenerationParams, Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification, - GenerationAiEnginesResponse, + GenerationAiEnginesResponse, GenerationAITaskListOut, GenerationAITaskQueryParams, } from '../types'; // ── Auth ────────────────────────────────────────────────── @@ -309,3 +309,16 @@ export async function adminGenerateVideo( export async function getGenerationAiEngines(): Promise { return api.get(`/generation-ai/engines`); } + +export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryParams): Promise { + 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(`/generation-ai/tasks${qs ? `?${qs}` : ''}`); +} + diff --git a/video-gen-admin/src/pages/AdminGenerationAiRecords.tsx b/video-gen-admin/src/pages/AdminGenerationAiRecords.tsx new file mode 100644 index 00000000..57b38909 --- /dev/null +++ b/video-gen-admin/src/pages/AdminGenerationAiRecords.tsx @@ -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; +} + +const EMPTY_RESOURCE_STATE: PreviewResourceState = { + image: 'empty', + video: 'empty', + videoCover: 'empty', + references: {}, +}; + +const STATUS_MAP: Record = { + pending: { color: 'default', text: '待处理', icon: }, + generating: { color: 'warning', text: '生成中', icon: }, + completed: { color: 'success', text: '已完成', icon: }, + failed: { color: 'error', text: '失败', icon: }, +}; + +const PIPELINE_STAGE_MAP: Record = { + queued: '已入队', + creating_provider_task: '创建任务中', + waiting_remote: '等待生成', + result_ready: '结果就绪', + downloading: '下载中', + done: '完成', +}; + +const GEN_TYPE_MAP: Record = { + image: { text: '图片', color: 'purple', icon: }, + video: { text: '视频', color: 'geekblue', icon: }, +}; + +const isAbsoluteLikeUrl = (url: string): boolean => ( + /^(https?:)?\/\//i.test(url) || /^(blob|data):/i.test(url) +); + +const isBlobUrl = (url?: string | null): boolean => !!url && /^blob:/i.test(url.trim()); + +const apiUrl = (url?: string | null): string => { + if (!url) return ''; + const value = String(url).trim(); + if (!value) return ''; + if (isAbsoluteLikeUrl(value)) return value; + + if (!RESOURCE_BASE) { + return value.startsWith('/') ? value : `/${value}`; + } + return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`; +}; + +const truncateId = (value?: string | null): string => { + if (!value) return '-'; + return value.length > 12 ? `${value.slice(0, 8)}...` : value; +}; + +const safeDate = (value?: string | null): string => { + if (!value) return '-'; + return formatDate(value); +}; + +const isEmptyValue = (value: React.ReactNode): boolean => ( + value === null || value === undefined || value === '' +); + +const isUrlExpired = (url?: string | null): boolean => { + if (!url || isBlobUrl(url)) return false; + + try { + const parsed = new URL(apiUrl(url), window.location.origin); + const expireValue = + parsed.searchParams.get('exp') || + parsed.searchParams.get('expires') || + parsed.searchParams.get('expire') || + parsed.searchParams.get('expires_at') || + parsed.searchParams.get('x-expires'); + + if (!expireValue) return false; + + const expireNumber = Number(expireValue); + if (!Number.isFinite(expireNumber)) return false; + + const expireMs = expireNumber > 10_000_000_000 ? expireNumber : expireNumber * 1000; + return Date.now() >= expireMs; + } catch { + return false; + } +}; + +const getInitialResourceState = (url?: string | null): ResourceState => { + if (!url) return 'empty'; + if (isBlobUrl(url)) return 'invalid'; + if (isUrlExpired(url)) return 'invalid'; + return 'checking'; +}; + +const getReferenceUrl = (ref: GenerationAIMediaReference): string | undefined => { + const value = ref.url || ref.mediaUrl || ref.fileUrl; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +}; + +const getReferenceType = (ref: GenerationAIMediaReference): string => { + const rawType = String(ref.type || ref.mediaType || ref.mimeType || '').toLowerCase(); + const url = getReferenceUrl(ref)?.toLowerCase() || ''; + + if (rawType.includes('video') || /\.(mp4|mov|webm|m4v)(\?|$)/i.test(url)) return 'video'; + if (rawType.includes('image') || /\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(url)) return 'image'; + return rawType || 'unknown'; +}; + +const getReferenceKey = (ref: GenerationAIMediaReference, index: number): string => { + const refUrl = getReferenceUrl(ref) || 'empty'; + return `${index}-${refUrl}`; +}; + +const getMediaInvalidText = (url?: string | null, type = '资源'): string => { + if (isBlobUrl(url)) return '本地临时素材已失效'; + if (isUrlExpired(url)) return `${type}链接已超时,请刷新列表或重新搜索后再查看`; + return `${type}加载失败,请刷新列表或重新搜索后再查看`; +}; + +const MediaPlaceholder: React.FC<{ + text: string; + minHeight?: number; + compact?: boolean; + action?: React.ReactNode; +}> = ({ text, minHeight = 240, compact = false, action }) => ( +
+ {text} + {action} +
+); + +const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label, value }) => ( +
+ {label} + {isEmptyValue(value) ? '-' : value} +
+); + +const AdminGenerationAiRecords: React.FC = () => { + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + const [filterStatus, setFilterStatus] = useState(''); + const [filterGenType, setFilterGenType] = useState(''); + const [inputUserId, setInputUserId] = useState(''); + const [inputUserName, setInputUserName] = useState(''); + const [queryUserId, setQueryUserId] = useState(''); + const [queryUserName, setQueryUserName] = useState(''); + const [reloadKey, setReloadKey] = useState(0); + + const [preview, setPreview] = useState(null); + const [resourceState, setResourceState] = useState(EMPTY_RESOURCE_STATE); + const [videoPlaying, setVideoPlaying] = useState(false); + const videoRef = useRef(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await getAdminGenerationAiTasks({ + genType: filterGenType || undefined, + status: filterStatus || undefined, + userId: queryUserId || undefined, + userName: queryUserName || undefined, + page, + pageSize: PAGE_SIZE, + }); + setRecords(res.items || []); + setTotal(res.total || 0); + } catch (e: any) { + message.error(e?.message || '加载创作记录失败'); + } finally { + setLoading(false); + } + }, [filterGenType, filterStatus, page, queryUserId, queryUserName]); + + useEffect(() => { + load(); + }, [load, reloadKey]); + + useEffect(() => { + if (!preview) { + setResourceState(EMPTY_RESOURCE_STATE); + return; + } + + const references = preview.mediaReferences || []; + const initialReferences = references.reduce>((acc, ref, index) => { + const refUrl = getReferenceUrl(ref); + acc[getReferenceKey(ref, index)] = getInitialResourceState(refUrl); + return acc; + }, {}); + + if (videoRef.current) { + videoRef.current.pause(); + videoRef.current.currentTime = 0; + } + + setVideoPlaying(false); + setResourceState({ + image: getInitialResourceState(preview.imageUrl), + video: getInitialResourceState(preview.videoUrl), + videoCover: getInitialResourceState(preview.videoCoverUrl), + references: initialReferences, + }); + }, [preview]); + + const handleSearch = () => { + setPage(1); + setQueryUserId(inputUserId.trim()); + setQueryUserName(inputUserName.trim()); + setReloadKey((v) => v + 1); + }; + + const handleOpenPreview = useCallback((record: GenerationAITaskOut) => { + setPreview(record); + }, []); + + const handleClosePreview = () => { + if (videoRef.current) { + videoRef.current.pause(); + } + setVideoPlaying(false); + setPreview(null); + }; + + const patchResourceState = (patch: Partial) => { + setResourceState((prev) => ({ ...prev, ...patch })); + }; + + const patchReferenceState = (key: string, state: ResourceState) => { + setResourceState((prev) => ({ + ...prev, + references: { + ...prev.references, + [key]: state, + }, + })); + }; + + const handlePlayVideo = () => { + if (!preview?.videoUrl) return; + + if (isUrlExpired(preview.videoUrl)) { + patchResourceState({ video: 'invalid' }); + message.warning('视频链接已超时,请刷新列表或重新搜索后再查看'); + return; + } + + setVideoPlaying(true); + window.setTimeout(() => { + videoRef.current?.play().catch(() => { + setVideoPlaying(false); + patchResourceState({ video: 'invalid' }); + message.warning('视频播放失败,请确认资源链接是否仍然有效'); + }); + }, 0); + }; + + const columns = useMemo(() => [ + { + title: '用户', key: 'user', width: 150, + render: (_: any, r: GenerationAITaskOut) => ( +
+ {r.userName || '未知用户'} +
{truncateId(r.userId)}
+
+ ), + }, + { + title: '类型', dataIndex: 'genType', width: 90, + render: (v: string) => { + const cfg = GEN_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null }; + return {cfg.text}; + }, + }, + { + title: '提示词', key: 'prompt', ellipsis: true, + render: (_: any, r: GenerationAITaskOut) => ( + + + {r.originalPrompt || '-'} + + + ), + }, + { + title: '参数', key: 'params', width: 180, + render: (_: any, r: GenerationAITaskOut) => ( + r.genType === 'video' ? ( + r.duration || r.aspectRatio || r.resolution ? ( + + {r.duration ? {r.duration}s : null} + {r.aspectRatio ? {r.aspectRatio} : null} + {r.resolution ? {r.resolution} : null} + + ) : 无参数 + ) : ( + r.imageSize || r.imageProportion || r.imagePx ? ( + + {r.imageSize ? {r.imageSize} : null} + {r.imageProportion ? {r.imageProportion} : null} + {r.imagePx ? {r.imagePx} : null} + + ) : 无参数 + ) + ), + }, + { + title: '积分', key: 'credits', width: 130, + render: (_: any, r: GenerationAITaskOut) => ( +
+
总: {r.creditsCost || 0}
+ {r.textCreditsCost > 0 ?
文字: {r.textCreditsCost}
: null} +
+ ), + }, + { + title: '状态', dataIndex: 'status', width: 100, + render: (v: string) => { + const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null }; + return {cfg.text}; + }, + }, + { + title: '阶段', dataIndex: 'pipelineStage', width: 120, + render: (v: string) => {PIPELINE_STAGE_MAP[v] || v || '-'}, + }, + { + title: '时间', key: 'time', width: 170, + render: (_: any, r: GenerationAITaskOut) => ( +
+
{safeDate(r.createdAt)}
+ {r.generatedAt ?
生成: {safeDate(r.generatedAt)}
: null} +
+ ), + }, + { + title: '操作', key: 'action', width: 90, fixed: 'right' as const, + render: (_: any, r: GenerationAITaskOut) => ( + + ), + }, + ], [handleOpenPreview]); + + const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null; + const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null; + + const renderResultImage = () => { + if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null; + + if (!preview.imageUrl) { + return ; + } + + if (resourceState.image === 'invalid') { + return ; + } + + const src = apiUrl(preview.imageUrl); + + return ( +
+ 生成图片 patchResourceState({ image: 'valid' })} + onError={() => patchResourceState({ image: 'invalid' })} + style={{ + display: resourceState.image === 'valid' ? 'block' : 'none', + width: '100%', + maxHeight: 560, + objectFit: 'contain', + background: '#fff', + }} + /> + {resourceState.image === 'checking' ? ( + + ) : null} +
+ ); + }; + + const renderVideoMask = () => { + if (!preview || videoPlaying || resourceState.video === 'invalid') return null; + + const canPlay = !!preview.videoUrl; + const playButton = canPlay ? ( + + + )} + bordered={false} + style={{ borderRadius: 16 }} + > + `共 ${t} 条`, + onChange: (p) => setPage(p), + }} + /> + + + + {preview?.genType === 'video' ? : } + 创作记录详情 + + )} + open={!!preview} + onCancel={handleClosePreview} + footer={null} + width={900} + destroyOnClose + > + {preview ? ( +
+
+ {previewTypeConfig ? {previewTypeConfig.text} : null} + {previewStatusConfig ? {previewStatusConfig.text} : null} + {preview.pipelineStage ? {PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage} : null} + {/*{preview.generationMode ? {preview.generationMode} : null}*/} +
+ +
+ + + +
+ +
+ 原始提示词 +
+ {preview.originalPrompt || '-'} +
+
+ + {preview.genType === 'video' ? ( +
+ + + +
+ ) : ( +
+ + + +
+ )} + + {preview.engineSnapshot ? ( +
+ 引擎快照 +
+ + + + +
+
+ ) : null} + +
+ {preview.creditsCost || 0}} /> + + + +
+ +
+ + + +
+ + {renderReferences()} + + {preview.status === 'completed' ? ( +
+ + {preview.genType === 'video' ? '生成视频' : '生成图片'} + + {preview.genType === 'video' ? renderResultVideo() : renderResultImage()} +
+ ) : null} + + {preview.status === 'failed' && preview.errorMessage ? ( +
+ 错误信息: {preview.errorMessage} +
+ ) : null} + +
+ 创建: {safeDate(preview.createdAt)} + 生成: {safeDate(preview.generatedAt)} +
+
+ ) : ( + + )} +
+ + ); +}; + +export default AdminGenerationAiRecords; diff --git a/video-gen-admin/src/pages/AdminLayout.tsx b/video-gen-admin/src/pages/AdminLayout.tsx index b22f3cd1..3dab2ec6 100644 --- a/video-gen-admin/src/pages/AdminLayout.tsx +++ b/video-gen-admin/src/pages/AdminLayout.tsx @@ -1,28 +1,18 @@ import React, { useEffect, useState } from 'react'; import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd'; import { - DashboardOutlined, - UserOutlined, - RobotOutlined, - SettingOutlined, - BellOutlined, - ThunderboltOutlined, - LogoutOutlined, - LockOutlined, - WalletOutlined, - CalculatorOutlined, - DollarOutlined, - AppstoreOutlined, - PlayCircleOutlined, - GiftOutlined, - HomeOutlined, - StarOutlined, - HeartOutlined, - CameraOutlined, - FileTextOutlined, - HistoryOutlined, - VideoCameraOutlined, - PictureOutlined, + MenuOutlined, PlusOutlined, EditOutlined, DeleteOutlined, + HomeOutlined, PlayCircleOutlined, WalletOutlined, RobotOutlined, + SettingOutlined, BellOutlined, UserOutlined, AppstoreOutlined, + FileTextOutlined, StarOutlined, HeartOutlined, CameraOutlined, + DashboardOutlined, CalculatorOutlined, DollarOutlined, GiftOutlined, + ThunderboltOutlined, FireOutlined, CloudOutlined, SmileOutlined, + TrophyOutlined, RocketOutlined, BulbOutlined, CodeOutlined, + PictureOutlined, VideoCameraOutlined, AudioOutlined, + MailOutlined, PhoneOutlined, GlobalOutlined, ShoppingCartOutlined, + TeamOutlined, BarChartOutlined, PieChartOutlined, LineChartOutlined, + SecurityScanOutlined, ApiOutlined, DatabaseOutlined, CloudServerOutlined, + LockOutlined, LogoutOutlined } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom'; import { useAdminStore } from '../store'; @@ -31,26 +21,28 @@ import { getMenuConfigs, adminChangePassword } from '../api'; const { Sider, Content } = Layout; const iconMap: Record = { - DashboardOutlined: , - UserOutlined: , - RobotOutlined: , - SettingOutlined: , - BellOutlined: , - LogoutOutlined: , - WalletOutlined: , - CalculatorOutlined: , - DollarOutlined: , - AppstoreOutlined: , - PlayCircleOutlined: , - GiftOutlined: , - HomeOutlined: , - StarOutlined: , - HeartOutlined: , - CameraOutlined: , - FileTextOutlined: , - HistoryOutlined: , - VideoCameraOutlined: , - PictureOutlined: , + HomeOutlined: , PlayCircleOutlined: , + WalletOutlined: , RobotOutlined: , + SettingOutlined: , BellOutlined: , + UserOutlined: , AppstoreOutlined: , + FileTextOutlined: , StarOutlined: , + HeartOutlined: , CameraOutlined: , + DashboardOutlined: , CalculatorOutlined: , + DollarOutlined: , GiftOutlined: , + ThunderboltOutlined: , FireOutlined: , + CloudOutlined: , SmileOutlined: , + TrophyOutlined: , RocketOutlined: , + BulbOutlined: , CodeOutlined: , + PictureOutlined: , VideoCameraOutlined: , + AudioOutlined: , MailOutlined: , + PhoneOutlined: , GlobalOutlined: , + ShoppingCartOutlined: , TeamOutlined: , + BarChartOutlined: , PieChartOutlined: , + LineChartOutlined: , SecurityScanOutlined: , + ApiOutlined: , DatabaseOutlined: , + CloudServerOutlined: , MenuOutlined: , + PlusOutlined: , EditOutlined: , DeleteOutlined: , + LockOutlined: , LogoutOutlined: }; const AdminLayout: React.FC = () => { diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index 7b0f8c18..2192ca6c 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -225,3 +225,87 @@ export interface AdminGenerationRecord { imageProportion?: 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; +} + diff --git a/video-gen-api/app/api/v1/generation_ai.py b/video-gen-api/app/api/v1/generation_ai.py index 19b27e7e..4ead2651 100644 --- a/video-gen-api/app/api/v1/generation_ai.py +++ b/video-gen-api/app/api/v1/generation_ai.py @@ -199,26 +199,47 @@ async def list_tasks( description="每页返回数量,范围 1~100", 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), 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( db, - current_user.id, + user_id, + user_name, gen_type, status, page, page_size, + is_admin, ) # ====================== 在这里加排序(最新在前)====================== - # 按 created_at 降序(没有则用 id 降序) - items_sorted = sorted( - items, - key=lambda x: x.created_at if x.created_at is not None else x.id, - reverse=False # 降序 - ) - return GenerationAITaskListOut(total=total, items=[record_to_out(i) for i in items_sorted]) + if not is_admin: + # 按 created_at 降序(没有则用 id 降序) + items_sorted = sorted( + items, + key=lambda x: x.created_at if x.created_at is not None else x.id, + reverse=False # 升序 + ) + 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( diff --git a/video-gen-api/app/schemas/generation_ai.py b/video-gen-api/app/schemas/generation_ai.py index f0faec23..1bf46826 100644 --- a/video-gen-api/app/schemas/generation_ai.py +++ b/video-gen-api/app/schemas/generation_ai.py @@ -250,6 +250,8 @@ class GenerationAITaskOut(BaseModel): "example": { "id": "0019e0a44895b6d837d", "source_type": "chat_task", + "user_id": None, + "user_name": None, "project_id": None, "gen_type": "image", "generation_mode": "chatapi_async", @@ -302,6 +304,14 @@ class GenerationAITaskOut(BaseModel): "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( None, description="项目ID。当前 /generation-ai 任务不绑定项目,通常为 null", @@ -374,6 +384,8 @@ class GenerationAITaskListOut(BaseModel): { "id": "0019e0a44895b6d837d", "source_type": "chat_task", + "user_id": None, + "user_name": None, "project_id": None, "gen_type": "image", "generation_mode": "chatapi_async", diff --git a/video-gen-api/app/services/generation_ai_service.py b/video-gen-api/app/services/generation_ai_service.py index a129f7e4..a4edcd8b 100644 --- a/video-gen-api/app/services/generation_ai_service.py +++ b/video-gen-api/app/services/generation_ai_service.py @@ -311,12 +311,14 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req 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) snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json)) return GenerationAITaskOut( 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, generation_mode=task.generation_mode, pipeline_stage=task.pipeline_stage, @@ -376,28 +378,55 @@ def engine_snapshot_out(snapshot: dict) -> dict: async def list_async_generation_tasks( db: AsyncSession, - user_id: str, + user_id: str | None, + user_name: str | None, gen_type: str | None, status: str | None, page: int, page_size: int, + is_admin: bool = False, ): - query = select(ChatGenerationTask).where( - ChatGenerationTask.user_id == user_id, + if is_admin: + 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.deleted_at.is_(None), ) + + if user_id: + query = query.where(ChatGenerationTask.user_id == user_id) + if gen_type: query = query.where(ChatGenerationTask.gen_type == gen_type) + if status: query = query.where(ChatGenerationTask.status == status) + count_query = select(func.count()).select_from(query.subquery()) total = (await db.execute(count_query)).scalar_one() + result = await db.execute( query.order_by(ChatGenerationTask.created_at.desc()) .offset((page - 1) * 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()) def _normalize_history_gen_type(gen_type: str | None) -> str: