feat: add number selection

This commit is contained in:
jialin
2026-05-25 22:42:02 +08:00
committed by jialin
parent 7bee337554
commit 858e0dae09
5 changed files with 293 additions and 39 deletions
@@ -0,0 +1,164 @@
import { LabelInfo } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { InputNumber } from 'antd';
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import styles from './styles.less';
interface NumberSelectionProps {
id?: string;
step?: number;
min?: number;
max?: number;
value?: number;
disabled?: boolean;
label?: React.ReactNode;
required?: boolean;
className?: string;
style?: React.CSSProperties;
styles?: {
input?: React.CSSProperties;
};
onChange?: (value: number) => void;
}
const PRESET_ITEMS = [1, 2, 4, 8];
const NumberSelection: React.FC<NumberSelectionProps> = ({
id,
step = 1,
min = 1,
max = 16,
value,
disabled,
label,
required,
className,
style,
onChange
}) => {
const intl = useIntl();
const showCustomInput = max > 8;
const computedItems = showCustomInput
? PRESET_ITEMS.filter((n) => n >= min && n <= max)
: Array.from(
{ length: Math.max(0, Math.floor((max - min) / step) + 1) },
(_, i) => min + i * step
);
const items = computedItems.length === 0 ? [0] : computedItems;
const [inputValue, setInputValue] = useState<number | null>(() =>
value !== undefined && value !== null && !PRESET_ITEMS.includes(value)
? value
: null
);
useEffect(() => {
if (value === undefined || value === null) {
setInputValue(null);
} else if (PRESET_ITEMS.includes(value)) {
setInputValue(null);
} else {
setInputValue(value);
}
}, [value]);
const handleSelect = (num: number) => {
if (disabled || num === value) {
return;
}
setInputValue(null);
onChange?.(num);
};
const handleInputChange = (num: number | null) => {
setInputValue(num);
};
const commitInput = () => {
if (disabled || inputValue === null || inputValue === value) {
return;
}
onChange?.(inputValue);
};
console.log('value+++', value, 'inputValue', items, inputValue);
return (
<div
id={id}
className={classNames(styles.wrapper, className, {
[styles.disabled]: disabled
})}
style={style}
role="radiogroup"
>
{label !== undefined && label !== null && (
<div className={styles.label}>
<LabelInfo label={label} required={required}></LabelInfo>
</div>
)}
<div className={styles.content}>
{items.map((num) => {
return (
<div
key={num}
role="radio"
aria-checked={num === value}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={classNames(styles.numberItem, {
[styles.active]: num === value
})}
onClick={() => handleSelect(num)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(num);
}
}}
>
{num}
</div>
);
})}
</div>
{showCustomInput && (
<div className={styles.inputWrapper}>
<div className={styles.line}></div>
<div
className={classNames(styles.inputContainer, {
[styles.hasValue]: !!inputValue
})}
>
<InputNumber
controls
variant="borderless"
min={min}
max={max}
step={step}
disabled={disabled}
value={inputValue || undefined}
style={{
fontSize: 14,
width: 140,
backgroundColor: 'var(--ant-color-bg-container)',
...style
}}
styles={{
input: {
fontWeight: inputValue ? 500 : 400,
...(styles?.input as React.CSSProperties)
}
}}
placeholder={intl.formatMessage({ id: 'common.option.other' })}
onChange={handleInputChange}
onBlur={commitInput}
onPressEnter={commitInput}
/>
</div>
</div>
)}
</div>
);
};
export default NumberSelection;
@@ -0,0 +1,92 @@
.wrapper {
display: flex;
align-items: center;
border-radius: var(--ant-border-radius-lg);
border: 1px solid var(--ant-color-border);
overflow: hidden;
background-color: var(--ant-color-fill-quaternary);
.inputContainer {
&.hasValue {
background-color: var(--ant-color-bg-container);
:global {
.ant-input-number-action {
border-color: var(--ant-color-split) !important;
}
.ant-input-number-actions {
width: 32px !important;
opacity: 1 !important;
}
}
}
margin-inline: 4px;
border-radius: 4px;
}
.inputWrapper {
display: flex;
align-items: center;
.line {
display: flex;
height: 22px;
border-left: 1px solid var(--ant-color-split);
}
}
.label {
display: flex;
align-items: center;
padding: 0 12px;
color: var(--ant-color-text-tertiary);
white-space: nowrap;
border-right: 1px solid var(--ant-color-split);
}
.content {
display: flex;
align-items: center;
flex: 1;
gap: 4px;
padding: 4px;
}
.numberItem {
position: relative;
color: var(--ant-color-text-secondary);
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 40px;
border-radius: 4px;
cursor: pointer;
border: 1px solid transparent;
outline: none;
transition:
color 0.2s,
background-color 0.2s,
border-color 0.2s;
&:hover {
background-color: var(--ant-color-fill-secondary);
}
&:focus-visible {
border-color: var(--ant-color-primary);
}
&.active {
background-color: var(--ant-color-bg-container);
color: var(--ant-color-text);
font-weight: 500;
border-color: var(--ant-color-split);
}
}
&.disabled {
cursor: not-allowed;
background-color: var(--ant-color-bg-container-disabled);
.label {
color: var(--ant-color-text-disabled);
}
.numberItem {
cursor: not-allowed;
color: var(--ant-color-text-disabled);
&:hover:not(.active) {
background-color: transparent;
}
}
}
}
@@ -224,13 +224,14 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}, [action, currentData, form, open, namespace]);
const handleFinish = async (values: InstanceFormValues) => {
await onFinish({
...values,
spec: {
...values.spec,
sshPublicKey: { name: sshKeyData?.name }
}
});
console.log('values=========', values);
// await onFinish({
// ...values,
// spec: {
// ...values.spec,
// sshPublicKey: { name: sshKeyData?.name }
// }
// });
};
useImperativeHandle(ref, () => ({
@@ -305,7 +306,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
id: 'gpuservice.instance.section.type'
}),
forceRender: true,
children: <InstanceTypeFormItem />
children: <InstanceTypeFormItem action={action} />
},
{
key: TABKeysMap.TEMPLATE,
@@ -1,6 +1,8 @@
import { InputNumber as CInputNumber } from '@gpustack/core-ui';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { Form } from 'antd';
import { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import InstanceTypeItem from '../components/instance-type-item';
@@ -45,7 +47,13 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
);
};
const InstanceTypeFormItem = () => {
interface InstanceTypeFormItemProps {
action: PageActionType;
}
const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
action
}) => {
const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const typeName = Form.useWatch(['spec', 'type'], form) as string | undefined;
@@ -65,8 +73,8 @@ const InstanceTypeFormItem = () => {
const maxGpuCount = useMemo(() => {
const onceMaxRequest =
selectedInstanceType?.status?.accelerator?.onceMaxRequest;
const num = onceMaxRequest ? Number(onceMaxRequest) : NaN;
return Number.isFinite(num) && num > 0 ? num : 1;
const num = onceMaxRequest ? Number(onceMaxRequest) : 0;
return num;
}, [selectedInstanceType]);
useEffect(() => {
@@ -135,24 +143,12 @@ const InstanceTypeFormItem = () => {
}
]}
>
<CInputNumber
<NumberSelection
min={1}
max={maxGpuCount}
precision={0}
label={
<Flex gap={4} align="center">
{intl.formatMessage({ id: 'gpuservice.instance.gpuCount' })}
<span>
(
{intl.formatMessage(
{ id: 'common.max' },
{ count: maxGpuCount }
)}
)
</span>
</Flex>
}
required
step={1}
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.instance.gpuCount' })}
/>
</Form.Item>
)}
@@ -106,16 +106,17 @@ const StorageVolume = ({
}
]}
/>
{action === PageAction.CREATE && (
<Button
type="link"
size="small"
style={{ marginBottom: 6 }}
onClick={() => setOverlayOpen(true)}
>
{intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button>
)}
{action === PageAction.CREATE &&
storageMode === StorageModeValueMap.Existing && (
<Button
type="link"
size="small"
style={{ marginBottom: 6 }}
onClick={() => setOverlayOpen(true)}
>
{intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button>
)}
</Flex>
{storageMode === StorageModeValueMap.Existing && (