424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
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;
|