This commit is contained in:
2026-07-03 10:05:21 +08:00
parent 5a98d7c1f2
commit 2d7b8ca334
7 changed files with 332 additions and 100 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CE6vN0iM.js"></script> <script type="module" crossorigin src="/assets/index-DNsZEwFL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+3
View File
@@ -611,6 +611,9 @@ export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryPa
if (params?.pageSize) q.set('page_size', String(params.pageSize)); if (params?.pageSize) q.set('page_size', String(params.pageSize));
if (params?.userId) q.set('user_id', params.userId); if (params?.userId) q.set('user_id', params.userId);
if (params?.userName) q.set('user_name', params.userName); if (params?.userName) q.set('user_name', params.userName);
if (params?.engineId) q.set('engine_id', params.engineId);
if (params?.createdStart) q.set('created_start', params.createdStart);
if (params?.createdEnd) q.set('created_end', params.createdEnd);
const qs = q.toString(); const qs = q.toString();
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`); return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
} }
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Button, Button,
Card, Card,
DatePicker,
Empty, Empty,
Input, Input,
message, message,
@@ -19,8 +20,10 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
EyeOutlined, EyeOutlined,
FileImageOutlined, FileImageOutlined,
LinkOutlined,
LoadingOutlined, LoadingOutlined,
PlayCircleOutlined, PlayCircleOutlined,
RobotOutlined,
SearchOutlined, SearchOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -28,12 +31,23 @@ import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types'; import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
const { RangePicker } = DatePicker;
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。 // 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
// 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。 // 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
/** 从 engineSnapshot 中提取引擎展示名称。 */
const getEngineName = (snapshot: Record<string, unknown> | null | undefined): string | null => {
if (!snapshot) return null;
return (snapshot.name as string)
|| (snapshot.modelName as string)
|| (snapshot.id as string)
|| null;
};
type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid'; type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid';
interface PreviewResourceState { interface PreviewResourceState {
@@ -200,6 +214,18 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div> </div>
); );
const todayStart = (): Date => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
};
const todayEnd = (): Date => {
const d = new Date();
d.setHours(23, 59, 59, 999);
return d;
};
const AdminGenerationAiRecords: React.FC = () => { const AdminGenerationAiRecords: React.FC = () => {
const [records, setRecords] = useState<GenerationAITaskOut[]>([]); const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -208,10 +234,14 @@ const AdminGenerationAiRecords: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string>(''); const [filterStatus, setFilterStatus] = useState<string>('');
const [filterGenType, setFilterGenType] = useState<string>(''); const [filterGenType, setFilterGenType] = useState<string>('');
const [filterEngineId, setFilterEngineId] = useState<string>('');
const [inputUserId, setInputUserId] = useState<string>(''); const [inputUserId, setInputUserId] = useState<string>('');
const [inputUserName, setInputUserName] = useState<string>(''); const [inputUserName, setInputUserName] = useState<string>('');
const [queryUserId, setQueryUserId] = useState<string>(''); const [queryUserId, setQueryUserId] = useState<string>('');
const [queryUserName, setQueryUserName] = useState<string>(''); const [queryUserName, setQueryUserName] = useState<string>('');
const [queryEngineId, setQueryEngineId] = useState<string>('');
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null); const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
@@ -227,6 +257,9 @@ const AdminGenerationAiRecords: React.FC = () => {
status: filterStatus || undefined, status: filterStatus || undefined,
userId: queryUserId || undefined, userId: queryUserId || undefined,
userName: queryUserName || undefined, userName: queryUserName || undefined,
engineId: queryEngineId || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}); });
@@ -237,7 +270,7 @@ const AdminGenerationAiRecords: React.FC = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filterGenType, filterStatus, page, queryUserId, queryUserName]); }, [filterGenType, filterStatus, page, queryUserId, queryUserName, queryEngineId, queryCreatedRange]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -274,6 +307,24 @@ const AdminGenerationAiRecords: React.FC = () => {
setPage(1); setPage(1);
setQueryUserId(inputUserId.trim()); setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim()); setQueryUserName(inputUserName.trim());
setQueryEngineId(filterEngineId);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterGenType('');
setFilterEngineId('');
setInputUserId('');
setInputUserName('');
setQueryUserId('');
setQueryUserName('');
setQueryEngineId('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
setReloadKey((v) => v + 1); setReloadKey((v) => v + 1);
}; };
@@ -358,6 +409,69 @@ const AdminGenerationAiRecords: React.FC = () => {
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
}, },
}, },
{
title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => {
const name = getEngineName(r.engineSnapshot as any);
return (
<Tooltip title={r.engineId || '未知引擎'} placement="topLeft">
<Tag icon={<RobotOutlined />} color="cyan">{name || r.engineId || '-'}</Tag>
</Tooltip>
);
},
},
{
title: '附件', key: 'references', width: 110,
render: (_: any, r: GenerationAITaskOut) => {
const refs = r.mediaReferences || [];
if (refs.length === 0) return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
const imgCount = refs.filter((ref) => getReferenceType(ref) === 'image').length;
const vidCount = refs.filter((ref) => getReferenceType(ref) === 'video').length;
const otherCount = refs.length - imgCount - vidCount;
return (
<Space size={4} wrap>
{imgCount > 0 ? <Tag color="purple" icon={<FileImageOutlined />}>{imgCount} </Tag> : null}
{vidCount > 0 ? <Tag color="geekblue" icon={<VideoCameraOutlined />}>{vidCount} </Tag> : null}
{otherCount > 0 ? <Tag>{otherCount} </Tag> : null}
</Space>
);
},
},
{
title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => {
if (r.status !== 'completed') {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
}
if (r.genType === 'video' && r.videoUrl) {
return (
<Button
size="small"
icon={<PlayCircleOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handleOpenExternalResource(r.videoUrl, '视频'); }}
>
</Button>
);
}
if (r.genType === 'image' && r.imageUrl) {
return (
<Button
size="small"
icon={<FileImageOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handleOpenExternalResource(r.imageUrl, '图片'); }}
>
</Button>
);
}
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
},
},
{ {
title: '提示词', key: 'prompt', ellipsis: true, title: '提示词', key: 'prompt', ellipsis: true,
render: (_: any, r: GenerationAITaskOut) => ( render: (_: any, r: GenerationAITaskOut) => (
@@ -789,16 +903,41 @@ const AdminGenerationAiRecords: React.FC = () => {
placeholder="类型筛选" placeholder="类型筛选"
value={filterGenType || undefined} value={filterGenType || undefined}
style={{ width: 120 }} style={{ width: 120 }}
onChange={(v) => { setFilterGenType(v || ''); setPage(1); }} onChange={(v) => { setFilterGenType(v || ''); setPage(1); setReloadKey((k) => k + 1); }}
options={[ options={[
{ value: 'image', label: '图片' }, { value: 'image', label: '图片' },
{ value: 'video', label: '视频' }, { value: 'video', label: '视频' },
]} ]}
/> />
<Select
allowClear
showSearch
placeholder="引擎筛选"
value={filterEngineId || undefined}
style={{ width: 180 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); setReloadKey((k) => k + 1); }}
optionFilterProp="label"
options={Array.from(
new Map(
records
.filter((r) => r.engineId)
.map((r) => [r.engineId, {
value: r.engineId,
label: getEngineName(r.engineSnapshot as any) || r.engineId,
}]),
).values(),
)}
/>
<RangePicker
showTime
value={createdRange}
onChange={(dates) => { setCreatedRange(dates); }}
placeholder={['创建开始', '创建结束']}
/>
<Input <Input
placeholder="用户ID搜索" placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 180 }} style={{ width: 150 }}
value={inputUserId} value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)} onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch} onPressEnter={handleSearch}
@@ -807,7 +946,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<Input <Input
placeholder="用户名搜索" placeholder="用户名搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 160 }} style={{ width: 150 }}
value={inputUserName} value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)} onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch} onPressEnter={handleSearch}
@@ -816,6 +955,9 @@ const AdminGenerationAiRecords: React.FC = () => {
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}> <Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button> </Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space> </Space>
</div> </div>
@@ -824,7 +966,7 @@ const AdminGenerationAiRecords: React.FC = () => {
loading={loading} loading={loading}
columns={columns as any} columns={columns as any}
dataSource={records} dataSource={records}
scroll={{ x: 1180 }} scroll={{ x: 1650 }}
pagination={{ pagination={{
current: page, current: page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -912,11 +1054,62 @@ const AdminGenerationAiRecords: React.FC = () => {
{renderReferences()} {renderReferences()}
{/* 附件快速查看区 */}
{(preview.mediaReferences && preview.mediaReferences.length > 0) ? (
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Space size={8} wrap>
{preview.mediaReferences.map((ref, idx) => {
const refUrl = getReferenceUrl(ref);
const refType = getReferenceType(ref);
const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`;
if (!refUrl) return null;
return (
<Button
key={idx}
size="small"
icon={refType === 'video' ? <PlayCircleOutlined /> : <LinkOutlined />}
onClick={() => handleOpenExternalResource(refUrl, refType === 'video' ? '视频附件' : '图片附件')}
>
{title}
</Button>
);
})}
</Space>
</div>
) : null}
{preview.status === 'completed' ? ( {preview.status === 'completed' ? (
<div> <div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'} <Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
</Typography.Text> {preview.genType === 'video' ? '生成视频' : '生成图片'}
</Typography.Text>
{preview.genType === 'video' && preview.videoUrl ? (
<Button
size="small"
type="link"
icon={<PlayCircleOutlined />}
onClick={() => handleOpenExternalResource(preview.videoUrl, '视频')}
style={{ padding: 0 }}
>
</Button>
) : null}
{preview.genType === 'image' && preview.imageUrl ? (
<Button
size="small"
type="link"
icon={<FileImageOutlined />}
onClick={() => handleOpenExternalResource(preview.imageUrl, '图片')}
style={{ padding: 0 }}
>
</Button>
) : null}
</div>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()} {preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
</div> </div>
) : null} ) : null}
+3
View File
@@ -426,6 +426,9 @@ export interface GenerationAITaskQueryParams {
pageSize?: number; pageSize?: number;
userId?: string; userId?: string;
userName?: string; userName?: string;
engineId?: string;
createdStart?: string;
createdEnd?: string;
} }
+19 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import select from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
@@ -232,6 +232,21 @@ async def list_tasks(
description="查询相关用户名的对应记录[管理后台]", description="查询相关用户名的对应记录[管理后台]",
examples=["demo"], examples=["demo"],
), ),
engine_id: str | None = Query(
None,
description="按引擎ID筛选[管理后台]",
examples=["0019e1697667d0eff39"],
),
created_start: datetime | None = Query(
None,
description="创建时间起始(含),ISO 格式,例如 2026-07-03T00:00:00",
examples=["2026-07-03T00:00:00"],
),
created_end: datetime | None = Query(
None,
description="创建时间截止(含),ISO 格式,例如 2026-07-03T23:59:59",
examples=["2026-07-03T23:59:59"],
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -250,6 +265,9 @@ async def list_tasks(
page, page,
page_size, page_size,
is_admin, is_admin,
engine_id=engine_id,
created_start=created_start,
created_end=created_end,
) )
# ====================== 在这里加排序(最新在前)====================== # ====================== 在这里加排序(最新在前)======================
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone, date
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -507,6 +507,9 @@ async def list_async_generation_tasks(
page: int, page: int,
page_size: int, page_size: int,
is_admin: bool = False, is_admin: bool = False,
engine_id: str | None = None,
created_start: datetime | None = None,
created_end: datetime | None = None,
): ):
if is_admin: if is_admin:
query = ( query = (
@@ -533,6 +536,18 @@ async def list_async_generation_tasks(
if status: if status:
query = query.where(ChatGenerationTask.status == status) query = query.where(ChatGenerationTask.status == status)
if engine_id:
query = query.where(ChatGenerationTask.engine_id == engine_id)
if created_start is not None or created_end is not None:
range_filters = []
if created_start is not None:
range_filters.append(ChatGenerationTask.created_at >= created_start)
if created_end is not None:
range_filters.append(ChatGenerationTask.created_at <= created_end)
if range_filters:
query = query.where(and_(*range_filters))
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()