Files
video-gen/video-gen-admin/src/pages/AdminGenerationRecords.tsx
T
2026-05-25 17:08:18 +08:00

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;