Files
video-gen/video-gen-admin/src/pages/AdminCreditRatios.tsx
T
root 5302d7f531 前台页面/conversation修改积分计算逻辑
1、后台页面/credit-ratios,需要选择视频,上边是模型价格,下边增加传入视频价格,也要输入对应的倍率、基础积分、每秒积分
2、前台/conversation获取积分逻辑的接口也要增加对应的传入视频的比例
3、然后前台根据用户上传视频自动获取对应的比例加上对应模型选择的积分计算总积分
4、后台提交也要验证对应的积分是否正确
5、多个视频总时长需限制15s,最低视频时长2s
2026-07-01 19:37:51 +08:00

411 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, 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;
}
interface CreditRatioFormValues {
modelConfigId: string;
genType: CreditGenType;
resolution: string;
ratio: number;
baseCredits: number;
perSecondCredits?: number;
inputVideoRatio?: number;
inputVideoBaseCredits?: number;
inputVideoPerSecondCredits?: 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;
}
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,
});
} else {
form.resetFields();
form.setFieldsValue({
genType: 'video',
ratio: 1.0,
baseCredits: 60,
perSecondCredits: 2,
inputVideoRatio: 1.0,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 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: '示例计算', key: 'example', width: 140,
render: (_: any, r: CreditRatio) => {
let total: number;
if (r.genType === 'image') {
total = Math.round(r.baseCredits * r.ratio);
} else {
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
total += Math.round(
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 10) * r.inputVideoRatio
);
}
}
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: 860 }}
/>
</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>
)}
</Form>
</Modal>
</div>
);
};
export default AdminCreditRatios;