解决冲突

This commit is contained in:
Lrd
2026-07-03 11:25:31 +08:00
16 changed files with 448 additions and 197 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CE6vN0iM.js"></script>
<script type="module" crossorigin src="/assets/index-875Agalq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<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?.userId) q.set('user_id', params.userId);
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();
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
}
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Empty,
Input,
message,
@@ -19,21 +20,35 @@ import {
CloseCircleOutlined,
EyeOutlined,
FileImageOutlined,
LinkOutlined,
LoadingOutlined,
PlayCircleOutlined,
RobotOutlined,
SearchOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { formatDate } from '../utils/formatDate';
const { RangePicker } = DatePicker;
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
// 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
const PAGE_SIZE = 20;
/** 从 engineSnapshot 中提取引擎展示名称。 */
const getEngineName = (snapshot: Record<string, unknown> | null | undefined): string | null => {
if (!snapshot) return null;
return (snapshot.name as string)
|| (snapshot.modelName as string)
|| (snapshot.id as string)
|| null;
};
type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid';
interface PreviewResourceState {
@@ -200,6 +215,10 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationAiRecords: React.FC = () => {
const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
const [total, setTotal] = useState(0);
@@ -208,10 +227,14 @@ const AdminGenerationAiRecords: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterGenType, setFilterGenType] = useState<string>('');
const [filterEngineId, setFilterEngineId] = useState<string>('');
const [inputUserId, setInputUserId] = useState<string>('');
const [inputUserName, setInputUserName] = useState<string>('');
const [queryUserId, setQueryUserId] = useState<string>('');
const [queryUserName, setQueryUserName] = useState<string>('');
const [queryEngineId, setQueryEngineId] = useState<string>('');
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [reloadKey, setReloadKey] = useState(0);
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
@@ -219,6 +242,13 @@ const AdminGenerationAiRecords: React.FC = () => {
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
// 资源预览弹窗(附件 / 最终结果)
const [resourcePreview, setResourcePreview] = useState<{
url: string;
type: 'image' | 'video';
title: string;
} | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
@@ -227,6 +257,9 @@ const AdminGenerationAiRecords: React.FC = () => {
status: filterStatus || undefined,
userId: queryUserId || undefined,
userName: queryUserName || undefined,
engineId: queryEngineId || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
page,
pageSize: PAGE_SIZE,
});
@@ -237,7 +270,7 @@ const AdminGenerationAiRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterGenType, filterStatus, page, queryUserId, queryUserName]);
}, [filterGenType, filterStatus, page, queryUserId, queryUserName, queryEngineId, queryCreatedRange]);
useEffect(() => {
load();
@@ -274,6 +307,24 @@ const AdminGenerationAiRecords: React.FC = () => {
setPage(1);
setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim());
setQueryEngineId(filterEngineId);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterGenType('');
setFilterEngineId('');
setInputUserId('');
setInputUserName('');
setQueryUserId('');
setQueryUserName('');
setQueryEngineId('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
setReloadKey((v) => v + 1);
};
@@ -341,6 +392,21 @@ const AdminGenerationAiRecords: React.FC = () => {
window.open(apiUrl(url), '_blank', 'noopener,noreferrer');
};
/** 弹窗预览资源(图片/视频) */
const handlePreviewResource = (url: string, type: 'image' | 'video', title: string) => {
if (isBlobUrl(url)) {
message.warning('本地临时素材已失效,暂无法查看');
return;
}
if (isUrlExpired(url)) {
message.warning('链接已超时,请刷新列表或重新搜索后再查看');
return;
}
setResourcePreview({ url: apiUrl(url), type, title });
};
const handleCloseResourcePreview = () => setResourcePreview(null);
const columns = useMemo(() => [
{
title: '用户', key: 'user', width: 150,
@@ -358,6 +424,69 @@ const AdminGenerationAiRecords: React.FC = () => {
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => {
const name = getEngineName(r.engineSnapshot as any);
return (
<Tooltip title={r.engineId || '未知引擎'} placement="topLeft">
<Tag icon={<RobotOutlined />} color="cyan">{name || r.engineId || '-'}</Tag>
</Tooltip>
);
},
},
{
title: '附件', key: 'references', width: 110,
render: (_: any, r: GenerationAITaskOut) => {
const refs = r.mediaReferences || [];
if (refs.length === 0) return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
const imgCount = refs.filter((ref) => getReferenceType(ref) === 'image').length;
const vidCount = refs.filter((ref) => getReferenceType(ref) === 'video').length;
const otherCount = refs.length - imgCount - vidCount;
return (
<Space size={4} wrap>
{imgCount > 0 ? <Tag color="purple" icon={<FileImageOutlined />}>{imgCount} </Tag> : null}
{vidCount > 0 ? <Tag color="geekblue" icon={<VideoCameraOutlined />}>{vidCount} </Tag> : null}
{otherCount > 0 ? <Tag>{otherCount} </Tag> : null}
</Space>
);
},
},
{
title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => {
if (r.status !== 'completed') {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
}
if (r.genType === 'video' && r.videoUrl) {
return (
<Button
size="small"
icon={<PlayCircleOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handleOpenExternalResource(r.videoUrl, '视频'); }}
>
</Button>
);
}
if (r.genType === 'image' && r.imageUrl) {
return (
<Button
size="small"
icon={<FileImageOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handleOpenExternalResource(r.imageUrl, '图片'); }}
>
</Button>
);
}
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
},
},
{
title: '提示词', key: 'prompt', ellipsis: true,
render: (_: any, r: GenerationAITaskOut) => (
@@ -795,10 +924,34 @@ const AdminGenerationAiRecords: React.FC = () => {
{ value: 'video', label: '视频' },
]}
/>
<Select
allowClear
showSearch
placeholder="引擎筛选"
value={filterEngineId || undefined}
style={{ width: 180 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
optionFilterProp="label"
options={Array.from(
new Map(
records
.filter((r) => r.engineId)
.map((r) => [r.engineId, {
value: r.engineId,
label: getEngineName(r.engineSnapshot as any) || r.engineId,
}]),
).values(),
)}
/>
<RangePicker
value={createdRange}
onChange={(dates) => { setCreatedRange(dates); }}
placeholder={['开始日期', '结束日期']}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 180 }}
style={{ width: 150 }}
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
@@ -807,7 +960,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<Input
placeholder="用户名搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 160 }}
style={{ width: 150 }}
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
@@ -816,6 +969,9 @@ const AdminGenerationAiRecords: React.FC = () => {
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
@@ -824,7 +980,7 @@ const AdminGenerationAiRecords: React.FC = () => {
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1180 }}
scroll={{ x: 1650 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
@@ -912,11 +1068,62 @@ const AdminGenerationAiRecords: React.FC = () => {
{renderReferences()}
{/* 附件快速查看区 */}
{(preview.mediaReferences && preview.mediaReferences.length > 0) ? (
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Space size={8} wrap>
{preview.mediaReferences.map((ref, idx) => {
const refUrl = getReferenceUrl(ref);
const refType = getReferenceType(ref);
const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`;
if (!refUrl) return null;
return (
<Button
key={idx}
size="small"
icon={refType === 'video' ? <PlayCircleOutlined /> : <LinkOutlined />}
onClick={() => handlePreviewResource(refUrl, refType === 'video' ? 'video' : 'image', title)}
>
{title}
</Button>
);
})}
</Space>
</div>
) : null}
{preview.status === 'completed' ? (
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
</Typography.Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
</Typography.Text>
{preview.genType === 'video' && preview.videoUrl ? (
<Button
size="small"
type="link"
icon={<PlayCircleOutlined />}
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
style={{ padding: 0 }}
>
</Button>
) : null}
{preview.genType === 'image' && preview.imageUrl ? (
<Button
size="small"
type="link"
icon={<FileImageOutlined />}
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
style={{ padding: 0 }}
>
</Button>
) : null}
</div>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
</div>
) : null}
@@ -936,6 +1143,34 @@ const AdminGenerationAiRecords: React.FC = () => {
<Empty description="暂无详情" />
)}
</Modal>
{/* 资源预览弹窗(附件 / 最终结果) */}
<Modal
title={resourcePreview?.title || '资源预览'}
open={!!resourcePreview}
onCancel={handleCloseResourcePreview}
footer={null}
width={resourcePreview?.type === 'video' ? 800 : 600}
destroyOnHidden
>
{resourcePreview ? (
resourcePreview.type === 'video' ? (
<video
key={resourcePreview.url}
src={resourcePreview.url}
controls
autoPlay
style={{ width: '100%', maxHeight: 600, background: '#000', borderRadius: 8 }}
/>
) : (
<img
src={resourcePreview.url}
alt={resourcePreview.title}
style={{ width: '100%', maxHeight: 600, objectFit: 'contain', borderRadius: 8 }}
/>
)
) : null}
</Modal>
</div>
);
};
+48 -26
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, Select, InputNumber,
Button, Card, Space, Table, Tag, Tooltip, Typography, message, Modal, Form, Input, Select, InputNumber,
} from 'antd';
import {
MenuOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
@@ -10,16 +10,16 @@ import { formatDate } from '../utils/formatDate';
interface OAuthApp {
id: string;
appId: string;
app_id: string;
secret: string;
status: number;
count: number;
openType: number;
authUrl?: string;
max_count: number;
open_type: number;
auth_url?: string;
company?: string;
createBy: string;
createdAt: string;
updatedAt: string;
create_by: string;
created_at: string;
updated_at: string;
}
const AdminOauthAppList: React.FC = () => {
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
const app = await getOauthApp(id);
setCurrentApp(app);
updateForm.setFieldsValue({
app_id: app.appId,
app_id: app.app_id,
secret: app.secret,
open_type: app.openType,
count: app.count,
auth_url: app.authUrl,
open_type: app.open_type,
count: app.max_count,
auth_url: app.auth_url,
company: app.company,
});
setUpdateModalVisible(true);
@@ -143,8 +143,19 @@ const AdminOauthAppList: React.FC = () => {
{ title: '应用ID', dataIndex: 'appId', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '应用密钥', dataIndex: 'secret', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// { title: '应用密钥', dataIndex: 'secret', width: 120,
// render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// },
{ title: '应用密钥', dataIndex: 'secret', width: 180,
render: (v: string) => {
if (!v) return '-';
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
</Tooltip>
);
},
},
{ title: '开户方式', dataIndex: 'openType', width: 90,
render: (v: number) => {
@@ -165,15 +176,26 @@ const AdminOauthAppList: React.FC = () => {
{ title: '状态', dataIndex: 'status', width: 80,
render: (v: number) => <Tag color={v === 1 ? 'green' : 'red'}>{v === 1 ? '正常' : '禁用'}</Tag>,
},
{ title: '授权URL', dataIndex: 'authUrl', width: 180,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// { title: '授权URL', dataIndex: 'authUrl', width: 180,
// render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// },
{ title: '授权URL', dataIndex: 'authUrl', width: 220,
render: (v: string) => {
if (!v) return '-';
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }} ellipsis>{v.length > 30 ? `${v.slice(0, 27)}...` : v}</Typography.Text>
</Tooltip>
);
},
},
{ title: '创建人', dataIndex: 'createBy', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
// { title: '创建时间', dataIndex: 'createdAt', width: 160,
// render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
// },
{ title: '创建时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
},
{ title: '更新时间', dataIndex: 'updatedAt', width: 160,
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
},
@@ -312,22 +334,22 @@ const AdminOauthAppList: React.FC = () => {
{currentApp && (
<div style={{ lineHeight: '2' }}>
<p><strong>ID:</strong> {currentApp.id}</p>
<p><strong>ID:</strong> {currentApp.appId}</p>
<p><strong>ID:</strong> {currentApp.app_id}</p>
<p><strong>:</strong> {currentApp.secret}</p>
<p><strong>:</strong> {(() => {
const typeMap: Record<number, string> = {
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3'
};
return typeMap[currentApp.openType] || currentApp.openType;
return typeMap[currentApp.open_type] || currentApp.open_type;
})()}</p>
<p><strong>:</strong> {currentApp.company || '-'}</p>
<p><strong>:</strong> {currentApp.count}</p>
<p><strong>:</strong> {currentApp.max_count}</p>
<p><strong>:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
<p><strong>URL:</strong> {currentApp.authUrl || '-'}</p>
<p><strong>:</strong> {currentApp.createBy}</p>
<p><strong>:</strong> {formatDate(currentApp.createdAt)}</p>
<p><strong>:</strong> {formatDate(currentApp.updatedAt)}</p>
<p><strong>URL:</strong> {currentApp.auth_url || '-'}</p>
<p><strong>:</strong> {currentApp.create_by}</p>
<p><strong>:</strong> {formatDate(currentApp.created_at)}</p>
<p><strong>:</strong> {formatDate(currentApp.updated_at)}</p>
</div>
)}
</Modal>
+3
View File
@@ -426,6 +426,9 @@ export interface GenerationAITaskQueryParams {
pageSize?: number;
userId?: string;
userName?: string;
engineId?: string;
createdStart?: string;
createdEnd?: string;
}