503 lines
20 KiB
TypeScript
503 lines
20 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
||
} from 'antd';
|
||
import {
|
||
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
|
||
} from '@ant-design/icons';
|
||
import {
|
||
getCreditRatios, saveCreditRatio,
|
||
deleteCreditRatio, getSystemConfigs,
|
||
updateSystemConfig, getGenerationAiEngines,
|
||
} from '../api';
|
||
import type { GenerationAiEngineOption } from '../types';
|
||
|
||
type CreditGenType = 'image' | 'video';
|
||
|
||
interface CreditRatio {
|
||
id: string;
|
||
modelConfigId: string;
|
||
genType: CreditGenType | string;
|
||
resolution: string;
|
||
ratio: number;
|
||
baseCredits: number;
|
||
perSecondCredits: number;
|
||
inputVideoRatio: number;
|
||
inputVideoBaseCredits: number;
|
||
inputVideoPerSecondCredits: number;
|
||
inputImageRatio: number;
|
||
inputImageBaseCredits: number;
|
||
inputImagePerImageCredits: number;
|
||
}
|
||
|
||
interface CreditRatioFormValues {
|
||
modelConfigId: string;
|
||
genType: CreditGenType;
|
||
resolution: string;
|
||
ratio: number;
|
||
baseCredits: number;
|
||
perSecondCredits?: number;
|
||
inputVideoRatio?: number;
|
||
inputVideoBaseCredits?: number;
|
||
inputVideoPerSecondCredits?: number;
|
||
inputImageRatio?: number;
|
||
inputImageBaseCredits?: number;
|
||
inputImagePerImageCredits?: number;
|
||
}
|
||
|
||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||
const DEFAULT_VIDEO_RESOLUTIONS = ['480p', '720p', '1080p'];
|
||
|
||
const AdminCreditRatios: React.FC = () => {
|
||
const [ratios, setRatios] = useState<CreditRatio[]>([]);
|
||
const [engines, setEngines] = useState<GenerationAiEngineOption[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
|
||
const [form] = Form.useForm<CreditRatioFormValues>();
|
||
const [textRate, setTextRate] = useState<number>(10);
|
||
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
|
||
const [savingTextRate, setSavingTextRate] = useState(false);
|
||
const genType = Form.useWatch('genType', form) || 'video';
|
||
const selectedEngineId = Form.useWatch('modelConfigId', form);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [ratioData, sysConfigs, enginesData] = await Promise.all([
|
||
getCreditRatios(),
|
||
getSystemConfigs(),
|
||
getGenerationAiEngines(),
|
||
]);
|
||
|
||
const imageEngines: GenerationAiEngineOption[] = (enginesData?.engine?.image || []).map(engine => ({
|
||
...engine,
|
||
genType: 'image' as const,
|
||
}));
|
||
const videoEngines: GenerationAiEngineOption[] = (enginesData?.engine?.video || []).map(engine => ({
|
||
...engine,
|
||
genType: 'video' as const,
|
||
}));
|
||
|
||
setRatios(ratioData);
|
||
setEngines([...videoEngines, ...imageEngines]);
|
||
|
||
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
|
||
if (textCfg) {
|
||
setTextRate(Number(textCfg.value) || 10);
|
||
setTextRateConfig({ id: textCfg.id });
|
||
}
|
||
} catch {
|
||
message.error('加载积分比例失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => { load(); }, []);
|
||
|
||
const currentEngineOptions = useMemo(() => (
|
||
engines
|
||
.filter(engine => engine.genType === genType)
|
||
.map(engine => ({
|
||
value: engine.id,
|
||
label: `${engine.name}${engine.modelName ? `(${engine.modelName})` : ''}`,
|
||
}))
|
||
), [engines, genType]);
|
||
|
||
const selectedEngine = useMemo(() => (
|
||
engines.find(engine => engine.id === selectedEngineId && engine.genType === genType)
|
||
), [engines, genType, selectedEngineId]);
|
||
|
||
const resolutionOptions = useMemo(() => {
|
||
if (genType === 'image') {
|
||
const sizes = selectedEngine?.supportedSizes ? Object.keys(selectedEngine.supportedSizes) : [];
|
||
return (sizes.length ? sizes : DEFAULT_IMAGE_SIZES).map(size => ({ value: size, label: size }));
|
||
}
|
||
|
||
const resolutions = selectedEngine?.supportedResolutions || [];
|
||
return (resolutions.length ? resolutions : DEFAULT_VIDEO_RESOLUTIONS).map(resolution => ({
|
||
value: resolution,
|
||
label: resolution,
|
||
}));
|
||
}, [genType, selectedEngine]);
|
||
|
||
const engineNameOf = (id: string, rowGenType?: string) => {
|
||
const engine = engines.find(item => item.id === id && (!rowGenType || item.genType === rowGenType));
|
||
return engine?.name || id;
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
const payload: any = {
|
||
model_config_id: values.modelConfigId,
|
||
gen_type: values.genType,
|
||
resolution: values.resolution,
|
||
ratio: values.ratio,
|
||
base_credits: values.baseCredits,
|
||
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
|
||
};
|
||
if (values.genType === 'video') {
|
||
payload.input_video_ratio = values.inputVideoRatio ?? 1.0;
|
||
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
|
||
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
|
||
}
|
||
payload.input_image_ratio = values.inputImageRatio ?? 1.0;
|
||
payload.input_image_base_credits = values.inputImageBaseCredits ?? 0;
|
||
payload.input_image_per_image_credits = values.inputImagePerImageCredits ?? 0;
|
||
if (modal.ratio) {
|
||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||
message.success('已更新');
|
||
} else {
|
||
await saveCreditRatio(payload);
|
||
message.success('已添加');
|
||
}
|
||
setModal({ open: false, ratio: null });
|
||
form.resetFields();
|
||
load();
|
||
} catch (e: any) {
|
||
if (e?.errorFields) return;
|
||
message.error(e?.message || '保存失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
try {
|
||
await deleteCreditRatio(id);
|
||
message.success('已删除');
|
||
load();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
};
|
||
|
||
const handleSaveTextRate = async () => {
|
||
if (!textRateConfig) return;
|
||
setSavingTextRate(true);
|
||
try {
|
||
await updateSystemConfig(textRateConfig.id, String(textRate));
|
||
message.success('文字积分费率已更新');
|
||
} catch (e: any) {
|
||
message.error(e?.message || '保存失败');
|
||
} finally {
|
||
setSavingTextRate(false);
|
||
}
|
||
};
|
||
|
||
const openEdit = (ratio?: CreditRatio) => {
|
||
setModal({ open: true, ratio: ratio || null });
|
||
if (ratio) {
|
||
form.setFieldsValue({
|
||
modelConfigId: ratio.modelConfigId,
|
||
genType: ratio.genType === 'image' ? 'image' : 'video',
|
||
resolution: ratio.resolution,
|
||
ratio: ratio.ratio,
|
||
baseCredits: ratio.baseCredits,
|
||
perSecondCredits: ratio.perSecondCredits,
|
||
inputVideoRatio: ratio.inputVideoRatio,
|
||
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||
inputImageRatio: ratio.inputImageRatio,
|
||
inputImageBaseCredits: ratio.inputImageBaseCredits,
|
||
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
|
||
});
|
||
} else {
|
||
form.resetFields();
|
||
form.setFieldsValue({
|
||
genType: 'video',
|
||
ratio: 1.0,
|
||
baseCredits: 60,
|
||
perSecondCredits: 2,
|
||
inputVideoRatio: 1.0,
|
||
inputVideoBaseCredits: 0,
|
||
inputVideoPerSecondCredits: 0.5,
|
||
inputImageRatio: 1.0,
|
||
inputImageBaseCredits: 0,
|
||
inputImagePerImageCredits: 0.5,
|
||
});
|
||
}
|
||
};
|
||
|
||
const columns = [
|
||
{
|
||
title: '类型', dataIndex: 'genType', width: 80,
|
||
render: (v: string) => <Tag color={v === 'image' ? 'cyan' : 'orange'}>{v === 'image' ? '图片' : '视频'}</Tag>,
|
||
},
|
||
{
|
||
title: '引擎', dataIndex: 'modelConfigId', width: 180,
|
||
render: (v: string, r: CreditRatio) => <Tag color={r.genType === 'image' ? 'cyan' : 'purple'}>{engineNameOf(v, r.genType)}</Tag>,
|
||
},
|
||
{
|
||
title: '分辨率/尺寸', dataIndex: 'resolution', width: 110,
|
||
render: (v: string) => {
|
||
const colors: Record<string, string> = { '480p': 'blue', '720p': 'blue', '1080p': 'blue', '4K': 'green', '2K': 'green' };
|
||
return <Tag color={colors[v] || 'default'}>{v}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '倍率', dataIndex: 'ratio', width: 100, sorter: (a: CreditRatio, b: CreditRatio) => a.ratio - b.ratio,
|
||
render: (v: number) => (
|
||
<Typography.Text strong style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>
|
||
x{v}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '基础积分', dataIndex: 'baseCredits', width: 100,
|
||
render: (v: number) => <Typography.Text>{v} 积分</Typography.Text>,
|
||
},
|
||
{
|
||
title: '每秒积分', dataIndex: 'perSecondCredits', width: 100,
|
||
render: (v: number, r: CreditRatio) => (
|
||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '传入视频',
|
||
children: [
|
||
{
|
||
title: '倍率', dataIndex: 'inputVideoRatio', width: 90,
|
||
render: (v: number, r: CreditRatio) => (
|
||
<Typography.Text>{r.genType === 'image' ? '-' : (
|
||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||
)}</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '基础积分', dataIndex: 'inputVideoBaseCredits', width: 100,
|
||
render: (v: number, r: CreditRatio) => (
|
||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 100,
|
||
render: (v: number, r: CreditRatio) => (
|
||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
{
|
||
title: '传入图片',
|
||
children: [
|
||
{
|
||
title: '倍率', dataIndex: 'inputImageRatio', width: 90,
|
||
render: (v: number) => (
|
||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||
),
|
||
},
|
||
{
|
||
title: '基础积分', dataIndex: 'inputImageBaseCredits', width: 100,
|
||
render: (v: number) => <Typography.Text>{v} 积分</Typography.Text>,
|
||
},
|
||
{
|
||
title: '每张积分', dataIndex: 'inputImagePerImageCredits', width: 100,
|
||
render: (v: number) => <Typography.Text>{v} 积分/张</Typography.Text>,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
title: '示例计算', key: 'example', width: 160,
|
||
render: (_: any, r: CreditRatio) => {
|
||
let total: number;
|
||
if (r.genType === 'image') {
|
||
total = Math.round(r.baseCredits * r.ratio);
|
||
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||
total += Math.round(
|
||
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||
);
|
||
}
|
||
} else {
|
||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
||
total += Math.round(
|
||
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
|
||
);
|
||
}
|
||
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||
total += Math.round(
|
||
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||
);
|
||
}
|
||
}
|
||
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||
},
|
||
},
|
||
{
|
||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||
render: (_: any, r: CreditRatio) => (
|
||
<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 style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
{/* Text Credit Rate */}
|
||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||
<Space>
|
||
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
|
||
<Typography.Text strong style={{ fontSize: 16 }}>文字积分费率</Typography.Text>
|
||
</Space>
|
||
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
|
||
保存
|
||
</Button>
|
||
</div>
|
||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
|
||
文字积分计算公式:ceil(总token数 x 费率 / 1000),最低1积分
|
||
</Typography.Text>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||
<Typography.Text>每1000 token消耗积分:</Typography.Text>
|
||
<Space.Compact style={{ width: 160 }}>
|
||
<InputNumber
|
||
min={0}
|
||
max={1000}
|
||
step={0.01}
|
||
value={textRate}
|
||
onChange={(v) => setTextRate(v || 0)}
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<Typography.Text>积分</Typography.Text>
|
||
</Space.Compact>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分
|
||
</Typography.Text>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Generation Credit Ratios */}
|
||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<Space>
|
||
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||
<Typography.Text strong style={{ fontSize: 16 }}>积分比例配置</Typography.Text>
|
||
<Tag color="purple">{ratios.length} 条规则</Tag>
|
||
</Space>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||
添加比例
|
||
</Button>
|
||
</div>
|
||
|
||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
|
||
视频积分公式:(基础积分 + 每秒积分 x 视频时长) x 模型倍率;图片积分公式:基础积分 x 模型倍率
|
||
</Typography.Text>
|
||
|
||
<Table
|
||
columns={columns}
|
||
dataSource={ratios}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={false}
|
||
scroll={{ x: 1500 }}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
title={<Space><CalculatorOutlined />{modal.ratio ? '编辑比例' : '添加比例'}</Space>}
|
||
open={modal.open}
|
||
onOk={handleSave}
|
||
onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }}
|
||
okText="保存" cancelText="取消" width={520}
|
||
>
|
||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item name="genType" label="生成类型" rules={[{ required: true, message: '请选择生成类型' }]}>
|
||
<Select
|
||
size="large"
|
||
options={[
|
||
{ value: 'video', label: '视频' },
|
||
{ value: 'image', label: '图片' },
|
||
]}
|
||
onChange={() => {
|
||
form.setFieldsValue({ modelConfigId: undefined as any, resolution: undefined as any });
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="modelConfigId" label="引擎" rules={[{ required: true, message: '请选择引擎' }]}>
|
||
<Select
|
||
size="large"
|
||
placeholder="请选择引擎"
|
||
options={currentEngineOptions}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
onChange={() => form.setFieldsValue({ resolution: undefined as any })}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="resolution" label={genType === 'image' ? '图片尺寸' : '分辨率'} rules={[{ required: true, message: genType === 'image' ? '请选择图片尺寸' : '请选择分辨率' }]}>
|
||
<Select size="large" placeholder={genType === 'image' ? '请选择图片尺寸' : '请选择分辨率'} options={resolutionOptions} />
|
||
</Form.Item>
|
||
<div style={{ display: 'flex', gap: 16 }}>
|
||
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true, message: '请输入倍率' }]}>
|
||
<InputNumber min={0.1} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true, message: '请输入基础积分' }]}>
|
||
<InputNumber min={0} max={1000} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
{genType !== 'image' && (
|
||
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true, message: '请输入每秒积分' }]}>
|
||
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
)}
|
||
</div>
|
||
|
||
{genType !== 'image' && (
|
||
<div style={{
|
||
marginTop: 8,
|
||
padding: '12px 16px',
|
||
backgroundColor: '#f5f5ff',
|
||
borderRadius: 8,
|
||
border: '1px solid #e0e0ff',
|
||
}}>
|
||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#4f46e5' }}>
|
||
传入视频价格
|
||
</Typography.Text>
|
||
<div style={{ display: 'flex', gap: 16 }}>
|
||
<Form.Item name="inputVideoRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频倍率' }]}>
|
||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="inputVideoBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频基础积分' }]}>
|
||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="inputVideoPerSecondCredits" label="每秒积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频每秒积分' }]}>
|
||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{
|
||
marginTop: 12,
|
||
padding: '12px 16px',
|
||
backgroundColor: '#f0fff4',
|
||
borderRadius: 8,
|
||
border: '1px solid #c6f6d5',
|
||
}}>
|
||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#059669' }}>
|
||
传入图片价格
|
||
</Typography.Text>
|
||
<div style={{ display: 'flex', gap: 16 }}>
|
||
<Form.Item name="inputImageRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片倍率' }]}>
|
||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="inputImageBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片基础积分' }]}>
|
||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="inputImagePerImageCredits" label="每张积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片每张积分' }]}>
|
||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
</div>
|
||
</div>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminCreditRatios;
|