456 lines
19 KiB
TypeScript
456 lines
19 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
Button, Card, Input, message, Modal, Select, Space, Table, Tag, Typography, Tooltip, Image,
|
|
} from 'antd';
|
|
import {
|
|
PlayCircleOutlined, EyeOutlined, ClockCircleOutlined, CheckCircleOutlined,
|
|
LoadingOutlined, CloseCircleOutlined, SearchOutlined, VideoCameraOutlined,
|
|
ExclamationCircleOutlined,
|
|
} from '@ant-design/icons';
|
|
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
|
|
import type { AdminGenerationRecord } from '../types';
|
|
import { formatDate } from '../utils/formatDate';
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
|
const apiUrl = (url?: string) => url ? (url.startsWith('http') ? url : `${API_BASE}${url}`) : '';
|
|
|
|
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
|
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
|
|
prompt_optimized: { color: 'processing', text: '待生成', icon: <ClockCircleOutlined /> },
|
|
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
|
|
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
|
|
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
|
|
};
|
|
|
|
const AdminGenerationRecords: React.FC = () => {
|
|
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize] = useState(20);
|
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
|
const [filterUserId, setFilterUserId] = useState<string>('');
|
|
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
|
const [updating, setUpdating] = useState<string | null>(null);
|
|
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string } | null>(null);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await getAdminGenerationRecords({
|
|
userId: filterUserId || undefined,
|
|
status: filterStatus || undefined,
|
|
page,
|
|
pageSize,
|
|
});
|
|
setRecords(res.items.map((item: any) => ({
|
|
id: item.id,
|
|
userId: item.userId,
|
|
username: item.username,
|
|
projectId: item.projectId,
|
|
projectName: item.projectName,
|
|
originalPrompt: item.originalPrompt,
|
|
optimizedPrompt: item.optimizedPrompt,
|
|
duration: item.duration,
|
|
aspectRatio: item.aspectRatio,
|
|
resolution: item.resolution,
|
|
status: item.status,
|
|
videoUrl: item.videoUrl,
|
|
references: item.references,
|
|
creditsCost: item.creditsCost,
|
|
textCreditsCost: item.textCreditsCost || 0,
|
|
textTokensUsed: item.textTokensUsed || 0,
|
|
videoTokensUsed: item.videoTokensUsed || 0,
|
|
errorMessage: item.errorMessage,
|
|
createdAt: item.createdAt,
|
|
generatedAt: item.generatedAt,
|
|
})));
|
|
setTotal(res.total);
|
|
} catch {
|
|
message.error('加载记录失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { load(); }, [page, filterStatus]);
|
|
|
|
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
|
|
setUpdating(recordId);
|
|
try {
|
|
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
|
|
message.success('状态已更新');
|
|
load();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '更新失败');
|
|
} finally {
|
|
setUpdating(null);
|
|
}
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
if (!genModal) return;
|
|
setUpdating(genModal.record.id);
|
|
try {
|
|
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution);
|
|
message.success('已提交视频生成');
|
|
setGenModal(null);
|
|
load();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '生成失败');
|
|
} finally {
|
|
setUpdating(null);
|
|
}
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '用户', key: 'user', width: 120,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<div>
|
|
<Typography.Text strong style={{ fontSize: 13 }}>{r.username}</Typography.Text>
|
|
<div style={{ fontSize: 11, color: '#94a3b8' }}>{r.userId.slice(0, 8)}...</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
|
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v}</Typography.Text>,
|
|
},
|
|
{
|
|
title: '提示词', key: 'prompt', ellipsis: true,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<Tooltip title={r.originalPrompt} placement="topLeft">
|
|
<Typography.Text style={{ fontSize: 12, color: '#475569' }} ellipsis>
|
|
{r.originalPrompt}
|
|
</Typography.Text>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
title: '参数', key: 'params', width: 140,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
r.duration ? (
|
|
<Space size={4} wrap>
|
|
<Tag>{r.duration}s</Tag>
|
|
<Tag>{r.aspectRatio}</Tag>
|
|
<Tag>{r.resolution}</Tag>
|
|
</Space>
|
|
) : <Tag color="default">待配置</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '积分', key: 'credits', width: 120,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<div style={{ fontSize: 12 }}>
|
|
{r.textCreditsCost > 0 && (
|
|
<div style={{ color: '#f59e0b' }}>文字: {r.textCreditsCost}</div>
|
|
)}
|
|
{r.creditsCost > 0 && (
|
|
<div style={{ color: '#6366f1' }}>视频: {r.creditsCost}</div>
|
|
)}
|
|
{r.textCreditsCost === 0 && r.creditsCost === 0 && (
|
|
<Typography.Text style={{ color: '#94a3b8' }}>0</Typography.Text>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'status', width: 90,
|
|
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: '时间', key: 'time', width: 140,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>
|
|
<div>{formatDate(r.createdAt)}</div>
|
|
{r.generatedAt && <div style={{ color: '#10b981' }}>生成: {formatDate(r.generatedAt)}</div>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
|
render: (_: any, r: AdminGenerationRecord) => (
|
|
<Space size={4} wrap>
|
|
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreview(r)}>
|
|
详情
|
|
</Button>
|
|
{r.status === 'generating' && (
|
|
<Button size="small" danger loading={updating === r.id}
|
|
onClick={() => {
|
|
Modal.confirm({
|
|
title: '确认操作',
|
|
icon: <ExclamationCircleOutlined />,
|
|
content: '确定将此记录标记为失败?',
|
|
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
|
});
|
|
}}>
|
|
标记失败
|
|
</Button>
|
|
)}
|
|
{r.status === 'failed' && (
|
|
<Button size="small" type="primary" danger loading={updating === r.id}
|
|
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p' })}>
|
|
重试生成
|
|
</Button>
|
|
)}
|
|
{r.status === 'prompt_optimized' && (
|
|
<>
|
|
<Button size="small" type="primary" loading={updating === r.id}
|
|
onClick={() => setGenModal({ record: r, ratio: '16:9', resolution: '720p' })}
|
|
style={{ background: '#6366f1', border: 'none' }}>
|
|
生成视频
|
|
</Button>
|
|
<Button size="small" danger loading={updating === r.id}
|
|
onClick={() => {
|
|
Modal.confirm({
|
|
title: '确认操作',
|
|
icon: <ExclamationCircleOutlined />,
|
|
content: '确定将此记录标记为失败?',
|
|
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
|
});
|
|
}}>
|
|
标记失败
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
|
<Space>
|
|
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
|
<Typography.Text strong style={{ fontSize: 16 }}>生成记录管理</Typography.Text>
|
|
<Tag color="purple">{total} 条记录</Tag>
|
|
</Space>
|
|
<Space>
|
|
<Select
|
|
placeholder="状态筛选"
|
|
allowClear
|
|
style={{ width: 120 }}
|
|
value={filterStatus || undefined}
|
|
onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
|
|
options={[
|
|
{ value: 'optimizing', label: '优化中' },
|
|
{ value: 'prompt_optimized', label: '待生成' },
|
|
{ value: 'generating', label: '生成中' },
|
|
{ value: 'completed', label: '已完成' },
|
|
{ value: 'failed', label: '失败' },
|
|
]}
|
|
/>
|
|
<Input
|
|
placeholder="用户ID搜索"
|
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
|
style={{ width: 200 }}
|
|
value={filterUserId}
|
|
onChange={(e) => setFilterUserId(e.target.value)}
|
|
onPressEnter={() => { setPage(1); load(); }}
|
|
allowClear
|
|
/>
|
|
<Button type="primary" onClick={() => { setPage(1); load(); }} style={{ borderRadius: 8 }}>
|
|
搜索
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
dataSource={records}
|
|
rowKey="id"
|
|
loading={loading}
|
|
scroll={{ x: 1100 }}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total,
|
|
onChange: setPage,
|
|
showSizeChanger: false,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Detail modal */}
|
|
<Modal
|
|
title={<Space><EyeOutlined />生成记录详情</Space>}
|
|
open={!!preview}
|
|
onCancel={() => setPreview(null)}
|
|
footer={null}
|
|
width={720}
|
|
>
|
|
{preview && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
|
{/* User & Project info */}
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<div style={{ flex: 1, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>用户</Typography.Text>
|
|
<Typography.Text strong>{preview.username}</Typography.Text>
|
|
</div>
|
|
<div style={{ flex: 1, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>项目</Typography.Text>
|
|
<Typography.Text strong>{preview.projectName}</Typography.Text>
|
|
</div>
|
|
<div style={{ flex: 1, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>状态</Typography.Text>
|
|
<Tag color={STATUS_MAP[preview.status]?.color} icon={STATUS_MAP[preview.status]?.icon}>
|
|
{STATUS_MAP[preview.status]?.text}
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Prompts */}
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>原始提示词</Typography.Text>
|
|
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc', border: '1px solid #f0f0f5' }}>
|
|
<Typography.Text style={{ fontSize: 13, color: '#475569', lineHeight: 1.7 }}>{preview.originalPrompt}</Typography.Text>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>优化后提示词</Typography.Text>
|
|
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(99,102,241,0.02)', border: '1px solid rgba(99,102,241,0.1)' }}>
|
|
<Typography.Text style={{ fontSize: 13, color: '#1a1a2e', lineHeight: 1.7 }}>{preview.optimizedPrompt}</Typography.Text>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credits */}
|
|
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>文字积分</Typography.Text>
|
|
<Typography.Text strong style={{ fontSize: 14, color: '#f59e0b' }}>{preview.textCreditsCost}</Typography.Text>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.textTokensUsed} tokens)</Typography.Text>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>视频积分</Typography.Text>
|
|
<Typography.Text strong style={{ fontSize: 14, color: '#6366f1' }}>{preview.creditsCost}</Typography.Text>
|
|
{preview.videoTokensUsed > 0 && (
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.videoTokensUsed} tokens)</Typography.Text>
|
|
)}
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>总积分</Typography.Text>
|
|
<Typography.Text strong style={{ fontSize: 14 }}>{preview.textCreditsCost + preview.creditsCost}</Typography.Text>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Video Params */}
|
|
{preview.duration ? (
|
|
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
{[
|
|
{ label: '时长', value: `${preview.duration}秒` },
|
|
{ label: '比例', value: preview.aspectRatio },
|
|
{ label: '分辨率', value: preview.resolution },
|
|
].map((item, i) => (
|
|
<div key={i} style={{ flex: 1 }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>{item.label}</Typography.Text>
|
|
<Typography.Text strong style={{ fontSize: 14 }}>{item.value}</Typography.Text>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc', textAlign: 'center' }}>
|
|
<Tag color="default">视频参数待用户配置</Tag>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reference images */}
|
|
{preview.references && preview.references.length > 0 && (
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>参考内容</Typography.Text>
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
{preview.references.map((ref, i) => (
|
|
<div key={i} style={{
|
|
width: 64, height: 64, borderRadius: 10, overflow: 'hidden',
|
|
border: '1px solid #e2e8f0',
|
|
}}>
|
|
{ref.type === 'image' ? (
|
|
<Image src={apiUrl(ref.url)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
) : (
|
|
<div style={{
|
|
width: '100%', height: '100%', background: '#1a1a2e',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 10,
|
|
}}>
|
|
视频
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Video preview */}
|
|
{preview.status === 'completed' && preview.videoUrl && (
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>生成视频</Typography.Text>
|
|
<video
|
|
src={apiUrl(preview.videoUrl)}
|
|
controls
|
|
style={{ width: '100%', maxHeight: 360, borderRadius: 12, background: '#000' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{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>
|
|
)}
|
|
|
|
{/* Timestamps */}
|
|
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#94a3b8' }}>
|
|
<span>创建: {formatDate(preview.createdAt)}</span>
|
|
{preview.generatedAt && <span>生成: {formatDate(preview.generatedAt)}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Generate modal */}
|
|
<Modal
|
|
title={<Space><PlayCircleOutlined />生成视频</Space>}
|
|
open={!!genModal}
|
|
onCancel={() => setGenModal(null)}
|
|
onOk={handleGenerate}
|
|
okText="提交生成"
|
|
cancelText="取消"
|
|
confirmLoading={genModal ? updating === genModal.record.id : false}
|
|
width={420}
|
|
>
|
|
{genModal && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
|
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>时长</Typography.Text>
|
|
<Typography.Text strong>{genModal.record.duration || 5}s</Typography.Text>
|
|
</div>
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>画面比例</Typography.Text>
|
|
<Select value={genModal.ratio} onChange={(v) => setGenModal(prev => prev ? { ...prev, ratio: v } : null)}
|
|
style={{ width: '100%' }}
|
|
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map(r => ({ value: r, label: r }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
|
<Select value={genModal.resolution} onChange={(v) => setGenModal(prev => prev ? { ...prev, resolution: v } : null)}
|
|
style={{ width: '100%' }}
|
|
options={['480p', '720p', '1080p'].map(r => ({ value: r, label: r }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminGenerationRecords;
|