733 lines
21 KiB
TypeScript
733 lines
21 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Card,
|
||
Table,
|
||
Button,
|
||
Input,
|
||
Select,
|
||
Space,
|
||
Form,
|
||
message,
|
||
Switch,
|
||
Typography,
|
||
InputNumber,
|
||
Tabs,
|
||
Modal,
|
||
Popconfirm,
|
||
} from 'antd';
|
||
import { SearchOutlined, ReloadOutlined, EditOutlined, PlusOutlined, DeleteOutlined, CheckOutlined } from '@ant-design/icons';
|
||
import { VmPricingApi } from '../services/api';
|
||
|
||
const { Option } = Select;
|
||
const { Title } = Typography;
|
||
const { TabPane } = Tabs;
|
||
|
||
interface VmSpecItem {
|
||
id: number;
|
||
spec_type: string;
|
||
cpu_cores: number;
|
||
memory_gb: number;
|
||
description?: string;
|
||
cost_price_per_minute: number;
|
||
markup_rate: number;
|
||
is_active: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface VmTemplateItem {
|
||
id: number;
|
||
spec_type: string;
|
||
template_id: string;
|
||
is_default: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
const VmPricing: React.FC = () => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [specsData, setSpecsData] = useState<VmSpecItem[]>([]);
|
||
const [templatesData, setTemplatesData] = useState<VmTemplateItem[]>([]);
|
||
const [specsTotal, setSpecsTotal] = useState(0);
|
||
const [templatesTotal, setTemplatesTotal] = useState(0);
|
||
const [specsPage, setSpecsPage] = useState(1);
|
||
const [templatesPage, setTemplatesPage] = useState(1);
|
||
const [specsPageSize, setSpecsPageSize] = useState(10);
|
||
const [templatesPageSize, setTemplatesPageSize] = useState(10);
|
||
const [activeTab, setActiveTab] = useState('specs');
|
||
const [editingCell, setEditingCell] = useState<{ rowId: number; field: string } | null>(null);
|
||
const [editingValue, setEditingValue] = useState<number>(0);
|
||
const [specModalVisible, setSpecModalVisible] = useState(false);
|
||
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
||
const [specsForm] = Form.useForm();
|
||
const [templatesForm] = Form.useForm();
|
||
const [templateForm] = Form.useForm();
|
||
const [specForm] = Form.useForm();
|
||
const [specTypes, setSpecTypes] = useState<string[]>([]);
|
||
|
||
// 获取规格类型列表(用于模板表单)
|
||
const fetchSpecTypes = async () => {
|
||
try {
|
||
const response = await VmPricingApi.getSpecs({ size: 1000 });
|
||
const types = [...new Set((response.data || []).map((item: VmSpecItem) => item.spec_type))];
|
||
setSpecTypes(types);
|
||
} catch (error) {
|
||
console.error('获取规格类型失败:', error);
|
||
}
|
||
};
|
||
|
||
// 获取规格数据
|
||
const fetchSpecs = async (searchParams: any = {}) => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await VmPricingApi.getSpecs({
|
||
...searchParams,
|
||
page: specsPage,
|
||
size: specsPageSize,
|
||
});
|
||
setSpecsData(response.data || []);
|
||
setSpecsTotal(response.total || 0);
|
||
} catch (error) {
|
||
console.error('获取规格数据失败:', error);
|
||
message.error('获取规格数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 获取模板数据
|
||
const fetchTemplates = async (searchParams: any = {}) => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await VmPricingApi.getTemplates({
|
||
...searchParams,
|
||
page: templatesPage,
|
||
size: templatesPageSize,
|
||
});
|
||
setTemplatesData(response.data || []);
|
||
setTemplatesTotal(response.total || 0);
|
||
} catch (error) {
|
||
console.error('获取模板数据失败:', error);
|
||
message.error('获取模板数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchSpecTypes(); // 获取规格类型列表
|
||
if (activeTab === 'specs') {
|
||
fetchSpecs();
|
||
} else {
|
||
fetchTemplates();
|
||
}
|
||
}, [specsPage, specsPageSize, templatesPage, templatesPageSize, activeTab]);
|
||
|
||
useEffect(() => {
|
||
// 当规格数据更新时,更新规格类型列表
|
||
if (specsData.length > 0) {
|
||
const types = [...new Set(specsData.map(item => item.spec_type))];
|
||
setSpecTypes(types);
|
||
}
|
||
}, [specsData]);
|
||
|
||
// 更新规格
|
||
const handleUpdateSpec = async (id: number, field: string, value: number | boolean) => {
|
||
try {
|
||
await VmPricingApi.updateSpec(id, { [field]: value });
|
||
message.success('更新成功');
|
||
setEditingCell(null);
|
||
fetchSpecs();
|
||
} catch (error) {
|
||
message.error('更新失败');
|
||
console.error('更新规格失败:', error);
|
||
}
|
||
};
|
||
|
||
// 更新状态
|
||
const handleUpdateStatus = async (id: number, isActive: boolean) => {
|
||
handleUpdateSpec(id, 'is_active', isActive);
|
||
};
|
||
|
||
// 搜索规格
|
||
const handleSpecsSearch = (values: any) => {
|
||
setSpecsPage(1);
|
||
fetchSpecs(values);
|
||
};
|
||
|
||
// 重置规格搜索
|
||
const handleSpecsReset = () => {
|
||
specsForm.resetFields();
|
||
setSpecsPage(1);
|
||
fetchSpecs();
|
||
};
|
||
|
||
// 搜索模板
|
||
const handleTemplatesSearch = (values: any) => {
|
||
setTemplatesPage(1);
|
||
fetchTemplates(values);
|
||
};
|
||
|
||
// 重置模板搜索
|
||
const handleTemplatesReset = () => {
|
||
templatesForm.resetFields();
|
||
setTemplatesPage(1);
|
||
fetchTemplates();
|
||
};
|
||
|
||
// 开始编辑
|
||
const handleEdit = (rowId: number, field: string, value: number) => {
|
||
setEditingCell({ rowId, field });
|
||
setEditingValue(value);
|
||
};
|
||
|
||
// 保存编辑
|
||
const handleSaveEdit = (rowId: number, field: string) => {
|
||
let valueToSave = editingValue;
|
||
// 加价率需要转换为小数(前端显示为百分比,后端存储为小数)
|
||
if (field === 'markup_rate') {
|
||
valueToSave = editingValue / 100;
|
||
}
|
||
handleUpdateSpec(rowId, field, valueToSave);
|
||
};
|
||
|
||
// 取消编辑
|
||
const handleCancelEdit = () => {
|
||
setEditingCell(null);
|
||
};
|
||
|
||
// 创建规格
|
||
const handleCreateSpec = async (values: any) => {
|
||
try {
|
||
// 转换加价率:前端输入百分比,后端存储小数
|
||
const data = {
|
||
...values,
|
||
markup_rate: values.markup_rate ? values.markup_rate / 100 : undefined,
|
||
};
|
||
await VmPricingApi.createSpec(data);
|
||
message.success('规格创建成功');
|
||
setSpecModalVisible(false);
|
||
specForm.resetFields();
|
||
fetchSpecs();
|
||
fetchSpecTypes(); // 刷新规格类型列表
|
||
} catch (error: any) {
|
||
message.error(error?.response?.data?.message || '规格创建失败');
|
||
console.error('创建规格失败:', error);
|
||
}
|
||
};
|
||
|
||
// 创建模板
|
||
const handleCreateTemplate = async (values: any) => {
|
||
try {
|
||
await VmPricingApi.createTemplate(values);
|
||
message.success('模板创建成功');
|
||
setTemplateModalVisible(false);
|
||
templateForm.resetFields();
|
||
fetchTemplates();
|
||
} catch (error: any) {
|
||
message.error(error?.response?.data?.message || '模板创建失败');
|
||
console.error('创建模板失败:', error);
|
||
}
|
||
};
|
||
|
||
// 删除规格
|
||
const handleDeleteSpec = async (id: number) => {
|
||
try {
|
||
await VmPricingApi.deleteSpec(id);
|
||
message.success('规格删除成功');
|
||
fetchSpecs();
|
||
fetchSpecTypes(); // 刷新规格类型列表
|
||
} catch (error) {
|
||
message.error('规格删除失败');
|
||
console.error('删除规格失败:', error);
|
||
}
|
||
};
|
||
|
||
// 删除模板
|
||
const handleDeleteTemplate = async (id: number) => {
|
||
try {
|
||
await VmPricingApi.deleteTemplate(id);
|
||
message.success('模板删除成功');
|
||
fetchTemplates();
|
||
} catch (error) {
|
||
message.error('模板删除失败');
|
||
console.error('删除模板失败:', error);
|
||
}
|
||
};
|
||
|
||
// 设置默认模板
|
||
const handleSetDefaultTemplate = async (id: number) => {
|
||
try {
|
||
await VmPricingApi.setDefaultTemplate(id);
|
||
message.success('默认模板设置成功');
|
||
fetchTemplates();
|
||
} catch (error) {
|
||
message.error('设置默认模板失败');
|
||
console.error('设置默认模板失败:', error);
|
||
}
|
||
};
|
||
|
||
|
||
// 规格表格列
|
||
const specsColumns = [
|
||
{
|
||
title: '配置类型',
|
||
dataIndex: 'spec_type',
|
||
key: 'spec_type',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: 'CPU核心',
|
||
dataIndex: 'cpu_cores',
|
||
key: 'cpu_cores',
|
||
width: 100,
|
||
},
|
||
{
|
||
title: '内存(GB)',
|
||
dataIndex: 'memory_gb',
|
||
key: 'memory_gb',
|
||
width: 100,
|
||
},
|
||
{
|
||
title: '描述',
|
||
dataIndex: 'description',
|
||
key: 'description',
|
||
width: 150,
|
||
render: (text: string) => text || '-',
|
||
},
|
||
{
|
||
title: '成本价($/分钟)',
|
||
dataIndex: 'cost_price_per_minute',
|
||
key: 'cost_price_per_minute',
|
||
width: 160,
|
||
render: (value: number, record: VmSpecItem) => {
|
||
if (editingCell?.rowId === record.id && editingCell?.field === 'cost_price_per_minute') {
|
||
return (
|
||
<Space.Compact>
|
||
<InputNumber
|
||
value={editingValue}
|
||
onChange={(val) => setEditingValue(val || 0)}
|
||
precision={8}
|
||
min={0}
|
||
style={{ width: 120 }}
|
||
autoFocus
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
onClick={() => handleSaveEdit(record.id, 'cost_price_per_minute')}
|
||
>
|
||
保存
|
||
</Button>
|
||
<Button size="small" onClick={handleCancelEdit}>
|
||
取消
|
||
</Button>
|
||
</Space.Compact>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||
onClick={() => handleEdit(record.id, 'cost_price_per_minute', value)}
|
||
>
|
||
<span>${value.toFixed(8)}</span>
|
||
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '加价率(%)',
|
||
dataIndex: 'markup_rate',
|
||
key: 'markup_rate',
|
||
width: 140,
|
||
render: (value: number, record: VmSpecItem) => {
|
||
if (editingCell?.rowId === record.id && editingCell?.field === 'markup_rate') {
|
||
return (
|
||
<Space.Compact>
|
||
<InputNumber
|
||
value={editingValue}
|
||
onChange={(val) => setEditingValue(val || 0)}
|
||
precision={4}
|
||
min={0}
|
||
max={100}
|
||
style={{ width: 100 }}
|
||
autoFocus
|
||
formatter={(value) => `${value}%`}
|
||
parser={(value) => value!.replace('%', '')}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
onClick={() => handleSaveEdit(record.id, 'markup_rate')}
|
||
>
|
||
保存
|
||
</Button>
|
||
<Button size="small" onClick={handleCancelEdit}>
|
||
取消
|
||
</Button>
|
||
</Space.Compact>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||
onClick={() => handleEdit(record.id, 'markup_rate', value * 100)}
|
||
>
|
||
<span>{(value * 100).toFixed(2)}%</span>
|
||
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'is_active',
|
||
key: 'is_active',
|
||
width: 80,
|
||
render: (value: boolean, record: VmSpecItem) => (
|
||
<Switch
|
||
checked={value}
|
||
onChange={(checked) => handleUpdateStatus(record.id, checked)}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 100,
|
||
render: (_: any, record: VmSpecItem) => (
|
||
<Popconfirm
|
||
title="确定要删除这个规格吗?"
|
||
description="删除后该规格的所有配置将无法使用"
|
||
onConfirm={() => handleDeleteSpec(record.id)}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
type="link"
|
||
danger
|
||
size="small"
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
];
|
||
|
||
// 模板表格列
|
||
const templatesColumns = [
|
||
{
|
||
title: '配置类型',
|
||
dataIndex: 'spec_type',
|
||
key: 'spec_type',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '模板ID',
|
||
dataIndex: 'template_id',
|
||
key: 'template_id',
|
||
width: 200,
|
||
},
|
||
{
|
||
title: '默认模板',
|
||
dataIndex: 'is_default',
|
||
key: 'is_default',
|
||
width: 100,
|
||
render: (value: boolean, record: VmTemplateItem) => (
|
||
value ? (
|
||
<span style={{ color: '#52c41a' }}>
|
||
<CheckOutlined /> 是
|
||
</span>
|
||
) : (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => handleSetDefaultTemplate(record.id)}
|
||
>
|
||
设为默认
|
||
</Button>
|
||
)
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 120,
|
||
render: (_: any, record: VmTemplateItem) => (
|
||
<Popconfirm
|
||
title="确定要删除这个模板吗?"
|
||
onConfirm={() => handleDeleteTemplate(record.id)}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
type="link"
|
||
danger
|
||
size="small"
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Card title={<Title level={4}>虚拟机价格配置</Title>}>
|
||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||
<TabPane tab="规格价格" key="specs">
|
||
<Space style={{ marginBottom: 16 }}>
|
||
<Form form={specsForm} onFinish={handleSpecsSearch} layout="inline">
|
||
<Form.Item name="spec_type">
|
||
<Input placeholder="配置类型" style={{ width: 150 }} />
|
||
</Form.Item>
|
||
<Form.Item name="status">
|
||
<Select placeholder="状态" allowClear style={{ width: 120 }}>
|
||
<Option value="active">启用</Option>
|
||
<Option value="inactive">禁用</Option>
|
||
</Select>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||
搜索
|
||
</Button>
|
||
<Button onClick={handleSpecsReset} icon={<ReloadOutlined />}>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => setSpecModalVisible(true)}
|
||
>
|
||
添加规格
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
dataSource={specsData}
|
||
columns={specsColumns}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{
|
||
current: specsPage,
|
||
pageSize: specsPageSize,
|
||
total: specsTotal,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
onChange: (page, pageSize) => {
|
||
setSpecsPage(page);
|
||
setSpecsPageSize(pageSize || 10);
|
||
},
|
||
}}
|
||
/>
|
||
</TabPane>
|
||
|
||
<TabPane tab="模板配置" key="templates">
|
||
<Space style={{ marginBottom: 16 }}>
|
||
<Form form={templatesForm} onFinish={handleTemplatesSearch} layout="inline">
|
||
<Form.Item name="spec_type">
|
||
<Input placeholder="配置类型" style={{ width: 150 }} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||
搜索
|
||
</Button>
|
||
<Button onClick={handleTemplatesReset} icon={<ReloadOutlined />}>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => setTemplateModalVisible(true)}
|
||
>
|
||
添加模板
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
dataSource={templatesData}
|
||
columns={templatesColumns}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{
|
||
current: templatesPage,
|
||
pageSize: templatesPageSize,
|
||
total: templatesTotal,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
onChange: (page, pageSize) => {
|
||
setTemplatesPage(page);
|
||
setTemplatesPageSize(pageSize || 10);
|
||
},
|
||
}}
|
||
/>
|
||
</TabPane>
|
||
</Tabs>
|
||
|
||
{/* 添加规格模态框 */}
|
||
<Modal
|
||
title="添加规格"
|
||
open={specModalVisible}
|
||
onCancel={() => {
|
||
setSpecModalVisible(false);
|
||
specForm.resetFields();
|
||
}}
|
||
footer={null}
|
||
>
|
||
<Form
|
||
form={specForm}
|
||
layout="vertical"
|
||
onFinish={handleCreateSpec}
|
||
>
|
||
<Form.Item
|
||
name="spec_type"
|
||
label="配置类型"
|
||
rules={[{ required: true, message: '请输入配置类型' }]}
|
||
>
|
||
<Input placeholder="如:2c4g, 4c8g, 6c12g" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="cpu_cores"
|
||
label="CPU核心数"
|
||
rules={[{ required: true, message: '请输入CPU核心数' }]}
|
||
>
|
||
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入CPU核心数" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="memory_gb"
|
||
label="内存(GB)"
|
||
rules={[{ required: true, message: '请输入内存大小' }]}
|
||
>
|
||
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入内存大小(GB)" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="description"
|
||
label="描述"
|
||
>
|
||
<Input placeholder="配置描述(可选)" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="cost_price_per_minute"
|
||
label="成本价($/分钟)"
|
||
rules={[{ required: true, message: '请输入成本价' }]}
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
precision={8}
|
||
style={{ width: '100%' }}
|
||
placeholder="请输入成本价(美元/分钟)"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="markup_rate"
|
||
label="加价率(%)"
|
||
initialValue={30}
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
max={100}
|
||
precision={2}
|
||
style={{ width: '100%' }}
|
||
placeholder="默认30%"
|
||
formatter={(value) => `${value}%`}
|
||
parser={(value) => value!.replace('%', '')}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="is_active"
|
||
label="启用"
|
||
valuePropName="checked"
|
||
initialValue={true}
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit">
|
||
创建
|
||
</Button>
|
||
<Button onClick={() => {
|
||
setSpecModalVisible(false);
|
||
specForm.resetFields();
|
||
}}>
|
||
取消
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* 添加模板模态框 */}
|
||
<Modal
|
||
title="添加模板"
|
||
open={templateModalVisible}
|
||
onCancel={() => {
|
||
setTemplateModalVisible(false);
|
||
templateForm.resetFields();
|
||
}}
|
||
footer={null}
|
||
>
|
||
<Form
|
||
form={templateForm}
|
||
layout="vertical"
|
||
onFinish={handleCreateTemplate}
|
||
>
|
||
<Form.Item
|
||
name="spec_type"
|
||
label="配置类型"
|
||
rules={[{ required: true, message: '请选择配置类型' }]}
|
||
>
|
||
<Select placeholder="请选择配置类型" showSearch>
|
||
{specTypes.map(type => (
|
||
<Option key={type} value={type}>{type}</Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="template_id"
|
||
label="模板ID"
|
||
rules={[{ required: true, message: '请输入模板ID' }]}
|
||
>
|
||
<Input placeholder="请输入E2B模板ID" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="is_default"
|
||
label="设为默认模板"
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit">
|
||
创建
|
||
</Button>
|
||
<Button onClick={() => {
|
||
setTemplateModalVisible(false);
|
||
templateForm.resetFields();
|
||
}}>
|
||
取消
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default VmPricing;
|
||
|