Files
video-gen/video-gen-admin/src/pages/AdminCreditRatios.tsx
T
2026-07-09 11:37:50 +08:00

503 lines
20 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, 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;