Files
video-gen/video-gen-admin/src/pages/AdminImageEngines.tsx
T

424 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
interface ImageEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedModels: string[];
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
maxImageCount: number;
generateUrl: string;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
outputFormat: '' | 'png' | 'jpeg';
}
function parseJsonArray(val: unknown): any[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); } catch { return []; }
}
return [];
}
function parseSizes(val: unknown): Record<string, Record<string, string>> {
if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record<string, Record<string, string>>;
if (typeof val === 'string') {
try { const p = JSON.parse(val); return (p && typeof p === 'object' && !Array.isArray(p)) ? p : {}; } catch { return {}; }
}
return {};
}
// Default size options with pixel mappings
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
"1K": {
"1:1": "1024x1024",
"4:3": "1152x864",
"3:4": "864x1152",
"16:9": "1312x736",
"9:16": "736x1312",
"3:2": "1248x832",
"2:3": "832x1248",
"21:9": "1568x672",
},
"2K": {
"1:1": "2048x2048",
"4:3": "2304x1728",
"3:4": "1728x2304",
"16:9": "2848x1600",
"9:16": "1600x2848",
"3:2": "2496x1664",
"2:3": "1664x2496",
"21:9": "3136x1344",
},
"4K": {
"1:1": "4096x4096",
"4:3": "4704x3520",
"3:4": "3520x4704",
"16:9": "5504x3040",
"9:16": "3040x5504",
"3:2": "4992x3328",
"2:3": "3328x4992",
"21:9": "6197x2656",
},
};
const ALL_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
const AdminImageEngines: React.FC = () => {
const [engines, setEngines] = useState<ImageEngine[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => {
setLoading(true);
try {
const data = await getImageEngines();
setEngines(data.map((e: any) => ({
...e,
supportedModels: parseJsonArray(e.supportedModels),
supportedSizes: parseSizes(e.supportedSizes),
})));
} catch {
message.error('加载图片引擎失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
// Build supportedSizes from form values
const sizes: Record<string, Record<string, string>> = {};
for (const tier of ["1K", "2K", "4K"]) {
const selected: string[] = values[`size_${tier}`] || [];
if (selected.length > 0) {
sizes[tier] = {};
for (const ratio of selected) {
sizes[tier][ratio] = SIZE_OPTIONS[tier]?.[ratio] || ratio;
}
}
}
const payload = {
name: values.name,
provider: values.provider,
api_base: values.apiBase,
api_key: values.apiKey,
model_name: values.modelName,
supported_models: JSON.stringify(values.supportedModels || []),
supported_sizes: JSON.stringify(sizes),
default_size: values.defaultSize || '2K',
max_image_count: values.maxImageCount ?? 0,
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
multi_image_max_images: values.multiImageMaxImages ?? 15,
max_reference_image_count: values.maxReferenceImageCount ?? 14,
output_format: values.outputFormat ?? '',
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
message.success('已更新');
} else {
await saveImageEngine(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 deleteImageEngine(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const openEdit = (engine?: ImageEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
const sizeFields: Record<string, string[]> = {};
for (const tier of ["1K", "2K", "4K"]) {
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
}
form.setFieldsValue({
...engine,
...sizeFields,
});
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
maxReferenceImageCount: 14, outputFormat: '',
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
maxImageCount: 0,
size_1K: ALL_RATIOS,
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: ImageEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #10b981, #059669)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PictureOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
</div>
</div>
),
},
{
title: '1K 支持比例', key: 'sizes_1k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["1K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="green">{ratio} {r.supportedSizes["1K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '2K 支持比例', key: 'sizes_2k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["2K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="blue">{ratio} {r.supportedSizes["2K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '4K 支持比例', key: 'sizes_4k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["4K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="purple">{ratio} {r.supportedSizes["4K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>,
},
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: ImageEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
最多 {r.multiGenerationEnabled ? (v || 1) : 1}
</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: ImageEngine) => (
<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 style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
<Tag color="green">{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: 1000 }}
/>
</Card>
<Modal
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={680}
>
<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="豆包文生图" 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-seedream-5-0-260128" size="large" />
</Form.Item>
<Form.Item name="supportedModels" label="支持模型列表">
<Select mode="tags" size="large" placeholder="输入模型ID后回车添加" tokenSeparators={[',', '']}
options={[{ value: 'doubao-seedream-5-0-260128' }]} />
</Form.Item>
{/* Size config */}
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 14 }}>尺寸配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
勾选每个档位支持的比例,前台选择后传对应像素值给SDK
</Typography.Text>
</div>
{["1K", "2K", "4K"].map(tier => (
<div key={tier} style={{
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
marginBottom: 12, border: '1px solid #f0f0f5',
}}>
<Typography.Text strong style={{ fontSize: 13, color: tier === '2K' ? '#3b82f6' : '#8b5cf6' }}>
{tier}
</Typography.Text>
<Form.Item name={`size_${tier}`} style={{ marginTop: 8, marginBottom: 0 }}>
<Checkbox.Group style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px 0' }}>
{ALL_RATIOS.map(ratio => (
<Checkbox key={ratio} value={ratio} style={{ fontSize: 12 }}>
{ratio} <span style={{ color: '#94a3b8', fontSize: 11 }}>{SIZE_OPTIONS[tier]?.[ratio]}</span>
</Checkbox>
))}
</div>
</Checkbox.Group>
</Form.Item>
</div>
))}
<Form.Item name="defaultSize" label="默认尺寸档位">
<Select size="large" options={[
{ value: '1K', label: '1K' },
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<Form.Item name="maxImageCount" label="最大图片数量">
<Input type="number" size="large" />
</Form.Item>
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong>多份生成能力</Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
管理后台只控制是否允许客户端选择多份及最大数量。客户端本次选择 2-5 份时,后端只调用一次火山同步组图 API;失败绝不降级成多次单图请求。
</Typography.Paragraph>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="multiImageMaxImages" label="组图输入输出总上限" rules={[{ required: true }]}>
<InputNumber min={1} max={15} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxReferenceImageCount" label="最大参考图数量" rules={[{ required: true }]}>
<InputNumber min={0} max={14} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="outputFormat" label="供应商输出格式">
<Select size="large" options={[
{ value: '', label: '不传(兼容不支持 output_format 的模型)' },
{ value: 'png', label: 'PNG' },
{ value: 'jpeg', label: 'JPEG' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<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>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminImageEngines;