337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
|
} from 'antd';
|
|
import {
|
|
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
|
} from '@ant-design/icons';
|
|
import { getVideoEngines, saveVideoEngine, deleteVideoEngine } from '../api';
|
|
|
|
interface VideoEngine {
|
|
id: string;
|
|
name: string;
|
|
provider: string;
|
|
apiBase: string;
|
|
apiKey: string;
|
|
modelName: string;
|
|
supportedRatios: string[];
|
|
supportedResolutions: string[];
|
|
supportedDurations: number[];
|
|
maxDuration: number;
|
|
maxImageCount: number;
|
|
maxVideoCount: number;
|
|
maxAudioCount: number;
|
|
supportsFirstLastFrame: boolean;
|
|
supportsUniversalReference: boolean;
|
|
isActive: boolean;
|
|
priority: number;
|
|
}
|
|
|
|
function parseJsonArray(val: unknown): any[] {
|
|
if (Array.isArray(val)) return val;
|
|
if (typeof val === 'string') {
|
|
try { return JSON.parse(val); } catch { return []; }
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const AdminVideoEngines: React.FC = () => {
|
|
const [engines, setEngines] = useState<VideoEngine[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
|
|
const [form] = Form.useForm();
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getVideoEngines();
|
|
setEngines(data.map((e: any) => ({
|
|
...e,
|
|
supportedRatios: parseJsonArray(e.supportedRatios),
|
|
supportedResolutions: parseJsonArray(e.supportedResolutions),
|
|
supportedDurations: parseJsonArray(e.supportedDurations),
|
|
})));
|
|
} catch {
|
|
message.error('加载视频引擎失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
const payload = {
|
|
name: values.name,
|
|
provider: values.provider,
|
|
api_base: values.apiBase,
|
|
api_key: values.apiKey,
|
|
model_name: values.modelName,
|
|
supported_ratios: JSON.stringify(values.supportedRatios || []),
|
|
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
|
|
supported_durations: JSON.stringify(values.supportedDurations || []),
|
|
max_duration: values.maxDuration ?? 15,
|
|
max_image_count: values.maxImageCount ?? 2,
|
|
max_video_count: values.maxVideoCount ?? 0,
|
|
max_audio_count: values.maxAudioCount ?? 0,
|
|
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
|
|
supports_universal_reference: values.supportsUniversalReference ?? true,
|
|
is_active: values.isActive ?? true,
|
|
priority: values.priority ?? 0,
|
|
};
|
|
if (modal.engine) {
|
|
await saveVideoEngine({ id: modal.engine.id, ...payload });
|
|
message.success('已更新');
|
|
} else {
|
|
await saveVideoEngine(payload);
|
|
message.success('已添加');
|
|
}
|
|
setModal({ open: false, engine: null });
|
|
form.resetFields();
|
|
load();
|
|
} catch (e: any) {
|
|
if (e?.errorFields) return;
|
|
message.error(e?.message || '保存失败');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await deleteVideoEngine(id);
|
|
message.success('已删除');
|
|
load();
|
|
} catch {
|
|
message.error('删除失败');
|
|
}
|
|
};
|
|
|
|
const openEdit = (engine?: VideoEngine) => {
|
|
setModal({ open: true, engine: engine || null });
|
|
if (engine) {
|
|
form.setFieldsValue(engine);
|
|
} else {
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
isActive: true, priority: 0,
|
|
maxDuration: 30,
|
|
maxImageCount: 2,
|
|
maxVideoCount: 0,
|
|
maxAudioCount: 0,
|
|
supportsFirstLastFrame: false,
|
|
supportsUniversalReference: true,
|
|
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
|
supportedResolutions: ['480p', '720p', '1080p'],
|
|
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
|
|
});
|
|
}
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '引擎名称', key: 'name', width: 180,
|
|
render: (_: any, r: VideoEngine) => (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: 8,
|
|
background: r.isActive
|
|
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
|
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: '#fff', fontSize: 16,
|
|
}}><PlayCircleOutlined /></div>
|
|
<div>
|
|
<Typography.Text strong>{r.name}</Typography.Text>
|
|
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '支持比例', dataIndex: 'supportedRatios', width: 200,
|
|
render: (ratios: string[]) => <Space size={2} wrap>{ratios.map(r => <Tag key={r}>{r}</Tag>)}</Space>,
|
|
},
|
|
{
|
|
title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150,
|
|
render: (res: string[]) => <Space size={2} wrap>{res.map(r => <Tag key={r} color="blue">{r}</Tag>)}</Space>,
|
|
},
|
|
{
|
|
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
|
|
render: (d: number[]) => <Tag color="orange">{d?.length ? `${Math.min(...d)}-${Math.max(...d)}s` : '-'}</Tag>,
|
|
},
|
|
{
|
|
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
|
|
render: (v: number) => <Tag color="purple">{v} 张</Tag>,
|
|
},
|
|
{
|
|
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
|
|
render: (v: number) => <Tag color="cyan">{v} 个</Tag>,
|
|
},
|
|
{
|
|
title: '最大音频', dataIndex: 'maxAudioCount', width: 100,
|
|
render: (v: number) => <Tag color={v > 0 ? 'geekblue' : 'default'}>{v || 0} 段</Tag>,
|
|
},
|
|
{
|
|
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
|
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
|
},
|
|
{
|
|
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
|
|
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'isActive', width: 80,
|
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
|
},
|
|
{
|
|
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
|
render: (_: any, r: VideoEngine) => (
|
|
<Space size={4}>
|
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
|
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
<Space>
|
|
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
|
<Typography.Text strong style={{ fontSize: 16 }}>视频引擎配置</Typography.Text>
|
|
<Tag color="purple">{engines.length} 个引擎</Tag>
|
|
</Space>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
|
添加引擎
|
|
</Button>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
dataSource={engines}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={false}
|
|
scroll={{ x: 1200 }}
|
|
/>
|
|
</Card>
|
|
|
|
<Modal
|
|
title={<Space><PlayCircleOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
|
|
open={modal.open}
|
|
onOk={handleSave}
|
|
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
|
|
okText="保存" cancelText="取消" width={620}
|
|
>
|
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
|
|
rules={[{ required: true }]}>
|
|
<Input placeholder="Seedance 2.0" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
|
|
rules={[{ required: true }]}>
|
|
<Select size="large" options={[
|
|
{ value: 'ark', label: '火山引擎 (Ark)' },
|
|
]} />
|
|
</Form.Item>
|
|
</div>
|
|
<Form.Item name="apiBase" label="API基础地址" rules={[{ required: true }]}>
|
|
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="apiKey" label="API Key">
|
|
<Input.Password placeholder="sk-****" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="modelName" label="模型名称">
|
|
<Input placeholder="doubao-seedance-2-0-260128" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="supportedRatios" label="支持比例">
|
|
<Select mode="multiple" size="large" options={[
|
|
{ value: '16:9', label: '16:9 (横屏)' },
|
|
{ value: '4:3', label: '4:3 (标准)' },
|
|
{ value: '1:1', label: '1:1 (方形)' },
|
|
{ value: '3:4', label: '3:4 (竖版)' },
|
|
{ value: '9:16', label: '9:16 (竖屏)' },
|
|
{ value: '21:9', label: '21:9 (超宽)' },
|
|
]} />
|
|
</Form.Item>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="supportedResolutions" label="支持分辨率" style={{ flex: 1 }}>
|
|
<Select mode="multiple" size="large" options={[
|
|
{ value: '480p' }, { value: '720p' }, { value: '1080p' },
|
|
]} />
|
|
</Form.Item>
|
|
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
|
|
<Select mode="multiple" size="large" options={
|
|
Array.from({ length: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
|
} />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
|
|
<Input type="number" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="maxImageCount" label="最大图片数量" style={{ flex: 1 }}>
|
|
<Input type="number" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="maxVideoCount" label="最大视频数量" style={{ flex: 1 }}>
|
|
<Input type="number" size="large" />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item
|
|
name="maxAudioCount"
|
|
label="最大参考音频数"
|
|
style={{ flex: 1 }}
|
|
extra="0 表示不支持音频参考,最大 3 段"
|
|
rules={[
|
|
{
|
|
validator: (_, value) => {
|
|
const n = Number(value ?? 0);
|
|
if (!Number.isInteger(n) || n < 0 || n > 3) {
|
|
return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Input type="number" size="large" min={0} max={3} />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
|
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
|
</Form.Item>
|
|
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
|
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
|
</Form.Item>
|
|
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
|
<Select size="large" options={[
|
|
{ value: 0, label: '0 (默认)' },
|
|
{ value: 1, label: '1' },
|
|
{ value: 2, label: '2' },
|
|
{ value: 3, label: '3' },
|
|
{ value: 5, label: '5' },
|
|
{ value: 10, label: '10 (最高)' },
|
|
]} />
|
|
</Form.Item>
|
|
</div>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminVideoEngines;
|