feat: templates, storage

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 3ae49dc826
commit 7ea7f6666d
52 changed files with 3604 additions and 188 deletions
@@ -1,13 +1,10 @@
import {
Input as CInput,
InputNumber as CInputNumber
} from '@gpustack/core-ui';
import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd';
import { FormData } from '../config/types';
const Basic = () => {
return (
<>
<div data-field="name">
<Form.Item<FormData>
name="name"
rules={[
@@ -19,49 +16,10 @@ const Basic = () => {
>
<CInput.Input label="实例名称" required />
</Form.Item>
<Form.Item<FormData>
name="image"
rules={[
{
required: true,
message: '请输入镜像'
}
]}
>
<CInput.Input label="镜像" required />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="gpu_count"
rules={[
{
required: true,
message: '请输入 GPU 数量'
}
]}
>
<CInputNumber min={0} precision={0} label="GPU 数量" required />
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="replicas"
rules={[
{
required: true,
message: '请输入副本数'
}
]}
>
<CInputNumber min={1} precision={0} label="副本数" required />
</Form.Item>
</div>
</div>
<Form.Item<FormData> name="description">
<CInput.TextArea label="描述" scaleSize />
</Form.Item>
</>
</div>
);
};
+159 -10
View File
@@ -1,9 +1,23 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import {
CollapsePanel,
IconFont,
ScrollSpyTabs,
useFinishFailed,
useScrollActiveChange,
useWrapperContext
} from '@gpustack/core-ui';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { mockStorageData } from '../../storage/config/mock-data';
import { mockTemplateData } from '../../templates/config/mock-data';
import { instanceTypeOptions, StorageModeValueMap } from '../config';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
import InstanceTypeFormItem from './instance-type';
import StorageVolume from './storage-volume';
import TemplateFormItem from './template';
interface InstanceFormProps {
ref?: any;
@@ -13,10 +27,89 @@ interface InstanceFormProps {
onFinish: (values: FormData) => Promise<void>;
}
const TABKeysMap = {
BASIC: 'basic',
INSTANCE_TYPE: 'instanceType',
TEMPLATE: 'template',
STORAGE: 'storage'
};
const requiredFields = {
[TABKeysMap.BASIC]: {
sort: 1,
fields: ['name']
},
[TABKeysMap.INSTANCE_TYPE]: {
sort: 2,
fields: ['instance_type_id']
},
[TABKeysMap.TEMPLATE]: {
sort: 3,
fields: ['template_id', 'gpu_count']
},
[TABKeysMap.STORAGE]: {
sort: 4,
fields: ['storage_mode', 'storage_id', 'local_storage_size_gb']
}
};
const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
const scrollTabsRef = useRef<any>(null);
const { getScrollElementScrollableHeight } = useWrapperContext();
const {
activeKey,
collapseKeys,
handleActiveChange,
handleOnCollapseChange,
updateActiveKey
} = useScrollActiveChange({
initalActiveKeys: [TABKeysMap.BASIC],
initialCollapseKeys: [
TABKeysMap.INSTANCE_TYPE,
TABKeysMap.TEMPLATE,
TABKeysMap.STORAGE
]
});
const segmentOptions = [
{
value: TABKeysMap.BASIC,
label: '基础信息',
icon: <IconFont type="icon-basic" />,
field: 'name'
},
{
value: TABKeysMap.INSTANCE_TYPE,
label: '实例类型',
icon: <IconFont type="icon-gpu1" />,
field: 'instanceType'
},
{
value: TABKeysMap.TEMPLATE,
label: '实例模板',
icon: <IconFont type="icon-model" />,
field: 'template'
},
{
value: TABKeysMap.STORAGE,
label: '存储卷',
icon: <IconFont type="icon-storage-outlined" />,
field: 'storage'
}
];
const onTargetChange = (key: string) => {
scrollTabsRef.current?.handleTargetChange(key);
};
const { handleOnFinishFailed } = useFinishFailed({
requiredFields,
onTargetChange,
updateActiveKey
});
useEffect(() => {
if (!open) {
@@ -28,16 +121,33 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
form.setFieldsValue({
name: currentData.name,
description: currentData.description,
instance_type: currentData.instance_type,
instance_type_id: currentData.instance_type_id,
template_id: currentData.template_id,
image: currentData.image,
gpu_count: currentData.gpu_count,
replicas: currentData.replicas
replicas: currentData.replicas,
storage_mode: currentData.storage_mode,
storage_id: currentData.storage_id,
local_storage_size_gb: currentData.local_storage_size_gb
});
return;
}
const defaultInstanceType = instanceTypeOptions[0];
const defaultTemplate = mockTemplateData[0];
const defaultStorage = mockStorageData[0];
form.setFieldsValue({
instance_type: defaultInstanceType?.name,
instance_type_id: defaultInstanceType?.id,
template_id: defaultTemplate?.id,
image: defaultTemplate?.image,
gpu_count: 1,
replicas: 1
replicas: 1,
storage_mode: StorageModeValueMap.Existing,
storage_id: defaultStorage?.id,
local_storage_size_gb: 50
});
}, [action, currentData, form, open]);
@@ -51,14 +161,53 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}));
return (
<Form
name="gpuServiceInstanceForm"
form={form}
onFinish={onFinish}
preserve={false}
<ScrollSpyTabs
ref={scrollTabsRef}
defaultTarget={TABKeysMap.BASIC}
segmentOptions={segmentOptions}
activeKey={activeKey}
setActiveKey={handleActiveChange}
segmentedTop={{
top: 0,
offsetTop: 96
}}
getScrollElementScrollableHeight={getScrollElementScrollableHeight}
>
<Basic />
</Form>
<Form
name="gpuServiceInstanceForm"
form={form}
onFinish={onFinish}
onFinishFailed={handleOnFinishFailed}
preserve={false}
>
<Basic />
<CollapsePanel
activeKey={collapseKeys}
accordion={false}
onChange={handleOnCollapseChange}
items={[
{
key: TABKeysMap.INSTANCE_TYPE,
label: '实例类型',
forceRender: true,
children: <InstanceTypeFormItem />
},
{
key: TABKeysMap.TEMPLATE,
label: '实例模板',
forceRender: true,
children: <TemplateFormItem />
},
{
key: TABKeysMap.STORAGE,
label: '存储卷',
forceRender: true,
children: <StorageVolume />
}
]}
/>
</Form>
</ScrollSpyTabs>
);
}
);
@@ -0,0 +1,91 @@
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { SearchOutlined } from '@ant-design/icons';
import { Empty, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import InstanceTypeList from '../components/instance-type-list';
import { instanceTypeOptions } from '../config';
const DrawerBody = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface InstanceTypeOverlayProps {
open: boolean;
value?: number;
onCancel: () => void;
onChange?: (value: number) => void;
}
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
open,
value,
onCancel,
onChange
}) => {
const [keyword, setKeyword] = useState('');
const filteredOptions = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) {
return instanceTypeOptions;
}
return instanceTypeOptions.filter((item) => {
return [
item.name,
String(item.gpu_count),
String(item.vram),
String(item.ram),
String(item.vCPU)
].some((text) => text.toLowerCase().includes(currentKeyword));
});
}, [keyword]);
const handleChange = (id: number) => {
onChange?.(id);
onCancel();
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="选择实例类型"
open={open}
width={600}
onCancel={onCancel}
footer={false}
getContainer={getOverlayContainer}
>
<DrawerBody>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索名称、GPU、显存、内存或 vCPU"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{filteredOptions.length > 0 ? (
<InstanceTypeList
value={value}
dataList={filteredOptions}
onChange={handleChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</DrawerBody>
</FormOverlayView>
);
};
export default InstanceTypeOverlay;
@@ -0,0 +1,199 @@
import {
AutoTooltip,
Input as CInput,
InputNumber as CInputNumber
} from '@gpustack/core-ui';
import { Button, Flex, Form, Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { instanceTypeOptions, InstanceTypeStatusValueMap } from '../config';
import { FormData } from '../config/types';
import InstanceTypeOverlay from './instance-type-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const SelectedCard = styled.div`
display: grid;
height: 102px;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--ant-color-border);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
`;
const SummaryTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
`;
const SummaryMeta = styled.div`
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 4px;
color: var(--ant-color-text-secondary);
font-size: 13px;
`;
interface InstanceTypePickerProps {
value?: number;
onChange?: (value: number) => void;
}
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
value,
onChange
}) => {
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
return instanceTypeOptions.find((item) => item.id === value);
}, [value]);
return (
<>
<SelectedCard>
<div>
<SummaryTitle>
<Flex gap={16}>
<AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例类型'}
</AutoTooltip>
<Tag
style={{
fontWeight: 400,
color: 'var(--ant-color-text-tertiary)'
}}
>
{selected?.gpu_count}
</Tag>
</Flex>
{/* {selected && (
<Tag color="success" style={{ margin: 0 }}>
可用
</Tag>
)} */}
</SummaryTitle>
<SummaryMeta>
<span> {selected?.vram ?? '-'} GiB</span>
<Flex gap={16}>
<span> {selected?.ram ?? '-'} GiB</span>
<span>CPU {selected?.vCPU ?? '-'}</span>
</Flex>
</SummaryMeta>
</div>
<Button onClick={() => setOpen(true)}></Button>
</SelectedCard>
<InstanceTypeOverlay
open={open}
value={value}
onCancel={() => setOpen(false)}
onChange={onChange}
/>
</>
);
};
const InstanceTypeFormItem = () => {
const form = Form.useFormInstance<FormData>();
const instanceTypeId = Form.useWatch('instance_type_id', form);
const selectedInstanceType = useMemo(() => {
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
}, [instanceTypeId]);
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
useEffect(() => {
if (!instanceTypeId) {
const defaultType = instanceTypeOptions.find(
(item) => item.status === InstanceTypeStatusValueMap.Available
);
if (defaultType) {
form.setFieldValue('instance_type_id', defaultType.id);
}
return;
}
const currentGpuCount = form.getFieldValue('gpu_count') ?? 1;
if (currentGpuCount > maxGpuCount) {
form.setFieldValue('gpu_count', maxGpuCount);
}
if (currentGpuCount < 1) {
form.setFieldValue('gpu_count', 1);
}
if (selectedInstanceType) {
form.setFieldValue('instance_type', selectedInstanceType.name);
}
}, [form, instanceTypeId, maxGpuCount, selectedInstanceType]);
return (
<>
<FieldBlock data-field="instanceType">
<Form.Item<FormData>
name="instance_type_id"
rules={[
{
required: true,
message: '请选择实例类型'
}
]}
>
<InstanceTypePicker />
</Form.Item>
</FieldBlock>
<Form.Item<FormData>
name="gpu_count"
rules={[
{
required: true,
message: '请输入 GPU 数量'
},
{
validator: (_, value) => {
if (value > maxGpuCount) {
return Promise.reject(
new Error(`当前实例类型最多支持 ${maxGpuCount} 个 GPU`)
);
}
return Promise.resolve();
}
}
]}
>
<CInputNumber
min={1}
max={maxGpuCount}
precision={0}
label={`GPU 数量`}
required
/>
</Form.Item>
<Form.Item<FormData> name="instance_type" hidden>
<CInput.Input />
</Form.Item>
</>
);
};
export const useSelectedInstanceType = () => {
const form = Form.useFormInstance<FormData>();
const instanceTypeId = Form.useWatch('instance_type_id', form);
return useMemo(() => {
return instanceTypeOptions.find((item) => item.id === instanceTypeId);
}, [instanceTypeId]);
};
export default InstanceTypeFormItem;
@@ -0,0 +1,118 @@
import { InputNumber as CInputNumber, Input, Select } from '@gpustack/core-ui';
import { Button, Flex, Form, Radio } from 'antd';
import styled from 'styled-components';
import { StorageStatusValueMap } from '../../storage/config';
import { mockStorageData } from '../../storage/config/mock-data';
import { StorageModeValueMap } from '../config';
import { FormData } from '../config/types';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const StorageHeader = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-bottom: 12px;
`;
const StorageVolume = () => {
const form = Form.useFormInstance<FormData>();
const storageMode = Form.useWatch('storage_mode', form);
return (
<FieldBlock data-field="storage">
<Flex
style={{
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6
}}
>
<Form.Item<FormData>
name="storage_mode"
style={{ marginBottom: 12 }}
rules={[
{
required: true,
message: '请选择存储卷'
}
]}
>
<Radio.Group
options={[
{
label: '持久存储',
value: StorageModeValueMap.Existing
},
{ label: '临时存储', value: StorageModeValueMap.Temporary }
]}
/>
</Form.Item>
<Button type="link" size="small" style={{ marginBottom: 6 }}>
</Button>
</Flex>
{storageMode === StorageModeValueMap.Existing && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="storage_id"
rules={[
{
required: true,
message: '请选择预存储卷'
}
]}
>
<Select
label="持久卷"
required
options={mockStorageData.map((item) => ({
label: `${item.name} / ${item.capacity_gb ?? '-'} GB`,
value: item.id,
disabled: item.status !== StorageStatusValueMap.Available
}))}
/>
</Form.Item>
</div>
</div>
)}
{storageMode === StorageModeValueMap.Temporary && (
<Form.Item<FormData>
name="local_storage_size_gb"
rules={[
{
required: true,
message: '请输入本地临时存储容量'
}
]}
>
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
</Form.Item>
)}
{/* 挂载路径 */}
<Form.Item<FormData>
name="mount_path"
rules={[
{
required: true,
message: '请输入存储挂载路径'
}
]}
>
<Input.Input
label="挂载路径"
required
placeholder="挂载路径须以 / 开头"
/>
</Form.Item>
</FieldBlock>
);
};
export default StorageVolume;
@@ -0,0 +1,193 @@
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
import { Button, Empty, Flex, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { mockTemplateData } from '../../templates/config/mock-data';
import { ListItem as TemplateItem } from '../../templates/config/types';
const TemplateGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const TemplateContent = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
color: var(--ant-color-text-secondary);
.name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
}
.info {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px;
color: var(--ant-color-text-tertiary);
}
.value {
color: var(--ant-color-text);
}
.icon {
color: var(--ant-color-text-quaternary);
}
`;
const DrawerBody = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number) => void;
dataList?: TemplateItem[];
}
interface TemplateOverlayProps {
open: boolean;
value?: number;
onCancel: () => void;
onChange?: (value: number) => void;
onCreate?: () => void;
}
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
value,
onChange,
dataList = mockTemplateData
}) => {
return (
<TemplateGrid>
{dataList.map((item: TemplateItem) => (
<TemplateCard
key={item.id}
clickable
ghost
hoverable
height={102}
active={value === item.id}
disabled={item.status !== 'enabled'}
onClick={() => onChange?.(item.id)}
>
<TemplateContent>
<div className="name">
<AutoTooltip ghost minWidth={20}>
{item.name}
</AutoTooltip>
{/* <Tag color={item.status === 'enabled' ? 'success' : 'default'}>
{item.status === 'enabled' ? '启用' : '停用'}
</Tag> */}
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-model" /> :
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{item.image || '-'}</span>
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-storage-outlined" /> :
</span>
<span className="value">
{item.volume_size_gb ?? '-'} GB {item.volume_mount_path || '-'}
</span>
</div>
</TemplateContent>
</TemplateCard>
))}
</TemplateGrid>
);
};
const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
open,
value,
onCancel,
onChange,
onCreate
}) => {
const [keyword, setKeyword] = useState('');
const filteredTemplates = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) {
return mockTemplateData;
}
return mockTemplateData.filter((item) => {
return [
item.name,
item.image,
item.vendor,
item.volume_mount_path,
String(item.volume_size_gb ?? '')
].some((text) => (text || '').toLowerCase().includes(currentKeyword));
});
}, [keyword]);
const handleChange = (id: number) => {
onChange?.(id);
onCancel();
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="选择实例模板"
open={open}
width={600}
onCancel={onCancel}
getContainer={getOverlayContainer}
footer={false}
>
<DrawerBody>
<Flex gap={8}>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索模板名称、镜像、厂商或挂载路径"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreate}>
</Button>
</Flex>
{filteredTemplates.length > 0 ? (
<TemplateSelector
value={value}
dataList={filteredTemplates}
onChange={handleChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</DrawerBody>
</FormOverlayView>
);
};
export default TemplateOverlay;
@@ -0,0 +1,209 @@
import { PageAction } from '@/config';
import { EditOutlined } from '@ant-design/icons';
import { AutoTooltip, Input as CInput } from '@gpustack/core-ui';
import { Button, Flex, Form, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import {
createGPUServiceTemplate,
updateGPUServiceTemplate
} from '../../templates/apis';
import AddTemplateModal from '../../templates/components/add-modal';
import { mockTemplateData } from '../../templates/config/mock-data';
import {
FormData as TemplateFormData,
ListItem as TemplateListItem
} from '../../templates/config/types';
import useCreateTemplate from '../../templates/hooks/use-create-template';
import { FormData } from '../config/types';
import { useSelectedInstanceType } from './instance-type';
import TemplateOverlay from './template-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const SelectedCard = styled.div`
display: grid;
height: 102px;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--ant-color-border);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
`;
const SummaryTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
`;
const SummaryMeta = styled.div`
display: grid;
gap: 4px;
color: var(--ant-color-text-secondary);
font-size: 13px;
`;
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number) => void;
onCreate?: () => void;
onEdit?: (row: TemplateListItem) => void;
}
const TemplatePicker: React.FC<TemplateSelectorProps> = ({
value,
onChange,
onCreate,
onEdit
}) => {
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
return mockTemplateData.find((item) => item.id === value);
}, [value]);
return (
<>
<SelectedCard>
<div>
<SummaryTitle>
<AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例模板'}
</AutoTooltip>
{/* {selected && (
<Tag
color={selected.status === 'enabled' ? 'success' : 'default'}
style={{ margin: 0 }}
>
{selected.status === 'enabled' ? '启用' : '停用'}
</Tag>
)} */}
</SummaryTitle>
<SummaryMeta>
<AutoTooltip ghost minWidth={20}>
<span>: {selected?.image || '-'}</span>
</AutoTooltip>
<span>
: {selected?.volume_size_gb ?? '-'} GB /{' '}
{selected?.volume_mount_path || '-'}
</span>
</SummaryMeta>
</div>
<Flex gap={8}>
<Button
type="text"
icon={<EditOutlined />}
disabled={!selected}
onClick={() => selected && onEdit?.(selected)}
>
</Button>
<Button onClick={() => setOpen(true)}></Button>
</Flex>
</SelectedCard>
<TemplateOverlay
open={open}
value={value}
onCancel={() => setOpen(false)}
onChange={onChange}
onCreate={() => {
// setOpen(false);
onCreate?.();
}}
/>
</>
);
};
const TemplateFormItem = () => {
const form = Form.useFormInstance<FormData>();
const templateId = Form.useWatch('template_id', form);
const selectedInstanceType = useSelectedInstanceType();
const maxGpuCount = selectedInstanceType?.gpu_count ?? 1;
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
useCreateTemplate();
const selectedTemplate = useMemo(() => {
return mockTemplateData.find((item) => item.id === templateId);
}, [templateId]);
useEffect(() => {
if (!templateId) {
form.setFieldValue('template_id', mockTemplateData[0]?.id);
return;
}
if (selectedTemplate) {
form.setFieldValue('image', selectedTemplate.image);
}
}, [form, selectedTemplate, templateId]);
const handleAddTemplate = () => {
openTemplateModal(PageAction.CREATE, '添加实例模板');
};
const handleEditTemplate = (row: TemplateListItem) => {
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
};
const handleTemplateModalOk = async (data: TemplateFormData) => {
try {
if (openTemplateModalStatus.action === PageAction.EDIT) {
await updateGPUServiceTemplate({
id: openTemplateModalStatus.currentData!.id,
data
});
} else {
await createGPUServiceTemplate({ data });
}
closeTemplateModal();
message.success('操作成功');
} catch (error) {
message.error('操作失败');
}
};
return (
<div>
<FieldBlock data-field="template">
<Form.Item<FormData>
name="template_id"
rules={[
{
required: true,
message: '请选择实例模板'
}
]}
>
<TemplatePicker
onCreate={handleAddTemplate}
onEdit={handleEditTemplate}
/>
</Form.Item>
</FieldBlock>
<Form.Item<FormData> name="image" hidden>
<CInput.Input />
</Form.Item>
<AddTemplateModal
action={openTemplateModalStatus.action}
open={openTemplateModalStatus.open}
title={openTemplateModalStatus.title}
currentData={openTemplateModalStatus.currentData}
onCancel={closeTemplateModal}
onOk={handleTemplateModalOk}
/>
</div>
);
};
export default TemplateFormItem;