From acef2ef89ac8bd535108af93295a921e0ccf6937 Mon Sep 17 00:00:00 2001 From: jialin Date: Thu, 30 Apr 2026 20:59:09 +0800 Subject: [PATCH] fix: add form validation --- src/locales/en-US/models.ts | 8 ++- src/locales/ja-JP/models.ts | 16 ++++- src/locales/ru-RU/models.ts | 16 ++++- src/locales/tr-TR/models.ts | 16 ++++- src/locales/zh-CN/models.ts | 8 ++- src/pages/llmodels/forms/lora-list-item.tsx | 48 +++++++------- src/pages/llmodels/forms/model-lora-list.tsx | 63 +++++++++++++++++-- .../llmodels/services/use-query-lora-list.ts | 5 +- src/pages/llmodels/style/lora-selection.less | 26 ++++++++ 9 files changed, 167 insertions(+), 39 deletions(-) create mode 100644 src/pages/llmodels/style/lora-selection.less diff --git a/src/locales/en-US/models.ts b/src/locales/en-US/models.ts index db421cb0..adad18cc 100644 --- a/src/locales/en-US/models.ts +++ b/src/locales/en-US/models.ts @@ -290,5 +290,11 @@ export default { 'models.instance.previousRun': 'Previous Run', 'models.instance.startHistory': 'Run History', 'models.instance.startHistory.tips': - 'Shows logs from the run before the last error-triggered restart.' + 'Shows logs from the run before the last error-triggered restart.', + 'models.form.lora.label': 'LoRA Adapter', + 'models.form.lora.add': 'Add LoRA Adapter', + 'models.form.lora.select': 'Select LoRA', + 'models.form.lora.name': 'LoRA name', + 'models.form.lora.rule.empty': 'Input cannot be empty', + 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' }; diff --git a/src/locales/ja-JP/models.ts b/src/locales/ja-JP/models.ts index fff635c7..88b5f157 100644 --- a/src/locales/ja-JP/models.ts +++ b/src/locales/ja-JP/models.ts @@ -290,7 +290,13 @@ export default { 'models.instance.previousRun': 'Previous Run', 'models.instance.startHistory': 'Run History', 'models.instance.startHistory.tips': - 'Shows logs from the run before the last error-triggered restart.' + 'Shows logs from the run before the last error-triggered restart.', + 'models.form.lora.label': 'LoRA Adapter', + 'models.form.lora.add': 'Add LoRA Adapter', + 'models.form.lora.select': 'Select LoRA', + 'models.form.lora.name': 'LoRA name', + 'models.form.lora.rule.empty': 'Input cannot be empty', + 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== @@ -393,5 +399,11 @@ export default { // 78. 'models.form.enableModelRoute.tips': 'Enable Model Route', // 79. 'models.table.modelView': 'Model View', // 80. 'models.table.instanceView': 'Instance View', -// 81. 'models.table.category': 'Category' +// 81. 'models.table.category': 'Category', +// 82. 'models.form.lora.label': 'LoRA Adapter', +// 83. 'models.form.lora.add': 'Add LoRA Adapter', +// 84. 'models.form.lora.select': 'Select LoRA', +// 85. 'models.form.lora.name': 'LoRA name', +// 86. 'models.form.lora.rule.empty': 'Input cannot be empty', +// 87. 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' // ========== End of To-Do List ========== diff --git a/src/locales/ru-RU/models.ts b/src/locales/ru-RU/models.ts index 8ab661d3..adbabf97 100644 --- a/src/locales/ru-RU/models.ts +++ b/src/locales/ru-RU/models.ts @@ -294,7 +294,13 @@ export default { 'models.instance.previousRun': 'Previous Run', 'models.instance.startHistory': 'Run History', 'models.instance.startHistory.tips': - 'Shows logs from the run before the last error-triggered restart.' + 'Shows logs from the run before the last error-triggered restart.', + 'models.form.lora.label': 'LoRA Adapter', + 'models.form.lora.add': 'Add LoRA Adapter', + 'models.form.lora.select': 'Select LoRA', + 'models.form.lora.name': 'LoRA name', + 'models.form.lora.rule.empty': 'Input cannot be empty', + 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== @@ -307,5 +313,11 @@ export default { // 5. 'models.form.backend.sglang': 'Built-in support for NVIDIA, AMD, Ascend, Moore Threads, MetaX, T-Head PPU devices.', // 6. 'models.table.modelView': 'Model List', // 7. 'models.table.instanceView': 'Instance List', -// 8. 'models.table.category': 'Category' +// 8. 'models.table.category': 'Category', +// 9. 'models.form.lora.label': 'LoRA Adapter', +// 10. 'models.form.lora.add': 'Add LoRA Adapter', +// 11. 'models.form.lora.select': 'Select LoRA', +// 12. 'models.form.lora.name': 'LoRA name', +// 13. 'models.form.lora.rule.empty': 'Input cannot be empty', +// 14. 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' // ========== End of To-Do List ========== diff --git a/src/locales/tr-TR/models.ts b/src/locales/tr-TR/models.ts index 70e54b02..de9d3385 100644 --- a/src/locales/tr-TR/models.ts +++ b/src/locales/tr-TR/models.ts @@ -290,11 +290,23 @@ export default { 'models.instance.previousRun': 'Previous Run', 'models.instance.startHistory': 'Run History', 'models.instance.startHistory.tips': - 'Shows logs from the run before the last error-triggered restart.' + 'Shows logs from the run before the last error-triggered restart.', + 'models.form.lora.label': 'LoRA Adapter', + 'models.form.lora.add': 'Add LoRA Adapter', + 'models.form.lora.select': 'Select LoRA', + 'models.form.lora.name': 'LoRA name', + 'models.form.lora.rule.empty': 'Input cannot be empty', + 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== // 1. 'models.table.modelView': 'Model List', // 2. 'models.table.instanceView': 'Instance List', -// 3. 'models.table.category': 'Category' +// 3. 'models.table.category': 'Category', +// 4. 'models.form.lora.label': 'LoRA Adapter', +// 5. 'models.form.lora.add': 'Add LoRA Adapter', +// 6. 'models.form.lora.select': 'Select LoRA', +// 7. 'models.form.lora.name': 'LoRA name', +// 8. 'models.form.lora.rule.empty': 'Input cannot be empty', +// 9. 'models.form.lora.rule.duplicate': 'LoRA name cannot be duplicated' // ========== End of To-Do List ========== diff --git a/src/locales/zh-CN/models.ts b/src/locales/zh-CN/models.ts index 00238e6e..103392ec 100644 --- a/src/locales/zh-CN/models.ts +++ b/src/locales/zh-CN/models.ts @@ -274,5 +274,11 @@ export default { 'models.instance.previousRun': '上一次运行', 'models.instance.startHistory': '运行记录', 'models.instance.startHistory.tips': - '显示上一次因错误自动重启之前的那次运行的日志。' + '显示上一次因错误自动重启之前的那次运行的日志。', + 'models.form.lora.label': 'LoRA 适配器', + 'models.form.lora.add': '添加 LoRA 适配器', + 'models.form.lora.select': '选择 LoRA', + 'models.form.lora.name': 'LoRA 名称', + 'models.form.lora.rule.empty': '输入不能为空', + 'models.form.lora.rule.duplicate': 'LoRA name 不能重复' }; diff --git a/src/pages/llmodels/forms/lora-list-item.tsx b/src/pages/llmodels/forms/lora-list-item.tsx index b177451b..09e4cc17 100644 --- a/src/pages/llmodels/forms/lora-list-item.tsx +++ b/src/pages/llmodels/forms/lora-list-item.tsx @@ -3,17 +3,21 @@ import { Input as CInput, Cascader as SealCascader } from '@gpustack/core-ui'; +import { useIntl } from '@umijs/max'; import _ from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import useQueryModelLoraList, { LoraOptionGroup } from '../services/use-query-lora-list'; +import loraSelectionStyles from '../style/lora-selection.less'; interface LoraListItemProps { item: { value: any[]; lora_name: string }; base: string; defaultDataList: LoraOptionGroup[]; selectedRepoNames: Set; + duplicateNames: Set; + validated: boolean; onChange: (partial: { value?: any[]; lora_name?: string }) => void; } @@ -22,8 +26,11 @@ const LoraListItem: React.FC = ({ base, defaultDataList, selectedRepoNames, + duplicateNames, + validated, onChange }) => { + const intl = useIntl(); const { dataList: ownSearchList, fetchData } = useQueryModelLoraList(); const [hasSearched, setHasSearched] = useState(false); @@ -79,11 +86,15 @@ const LoraListItem: React.FC = ({ }; const cascaderEmpty = !item.value || item.value.length === 0; - const nameEmpty = !item.lora_name; + const nameEmpty = !item.lora_name?.trim(); + const isDuplicate = + !!item.lora_name?.trim() && duplicateNames.has(item.lora_name.trim()); + const cascaderStatus = - cascaderEmpty && !nameEmpty ? ('error' as const) : undefined; + validated && cascaderEmpty ? ('error' as const) : 'success'; + const inputStatus = - nameEmpty && !cascaderEmpty ? ('error' as const) : undefined; + validated && (nameEmpty || isDuplicate) ? ('error' as const) : 'success'; const displayRender = (labels: any[]) => { return ( @@ -105,41 +116,27 @@ const LoraListItem: React.FC = ({ const optionNode = (option: any) => { const { data } = option; - if (data.isParent) { - return ( - - {data.label} - - ); - } return ( - + {data.label} ); }; return ( -
+
= ({ getPopupContainer={(triggerNode) => triggerNode.parentNode} >
); diff --git a/src/pages/llmodels/forms/model-lora-list.tsx b/src/pages/llmodels/forms/model-lora-list.tsx index 9120ce55..aa0041e7 100644 --- a/src/pages/llmodels/forms/model-lora-list.tsx +++ b/src/pages/llmodels/forms/model-lora-list.tsx @@ -1,4 +1,5 @@ import { MetadataList } from '@gpustack/core-ui'; +import { useIntl } from '@umijs/max'; import { Form } from 'antd'; import { useEffect, useMemo, useRef, useState } from 'react'; import { FormData, LoraListItem } from '../config/types'; @@ -8,6 +9,7 @@ import LoraItem from './lora-list-item'; type ItemValue = { value: any[]; lora_name: string }; const ModelLoraList = () => { + const intl = useIntl(); const form = Form.useFormInstance(); const huggingfaceRepoId = Form.useWatch('huggingface_repo_id', form); const modelScopeModelId = Form.useWatch('model_scope_model_id', form); @@ -18,6 +20,7 @@ const ModelLoraList = () => { const { dataList: defaultDataList, fetchData } = useQueryModelLoraList(); const [itemList, setItemList] = useState([]); + const [validated, setValidated] = useState(false); const initializedRef = useRef(false); const prevBaseRef = useRef(''); @@ -59,6 +62,19 @@ const ModelLoraList = () => { ); }, [itemList]); + const duplicateNames = useMemo(() => { + const counts = new Map(); + itemList.forEach((it) => { + const n = it.lora_name?.trim(); + if (n) counts.set(n, (counts.get(n) ?? 0) + 1); + }); + return new Set( + Array.from(counts.entries()) + .filter(([, c]) => c > 1) + .map(([n]) => n) + ); + }, [itemList]); + const syncFormField = (newItemList: ItemValue[]) => { const newFormList = newItemList .map((it) => ({ @@ -66,10 +82,16 @@ const ModelLoraList = () => { lora_repo_name: it.value?.[1] || '', lora_name: it.lora_name || '' })) - .filter((it) => it.lora_repo_name?.trim() && it.lora_name?.trim()); + .filter((it) => it.lora_repo_name?.trim() || it.lora_name?.trim()); form.setFieldValue('lora_list', newFormList); }; + useEffect(() => { + if (validated) { + form.validateFields(['lora_list']).catch(() => {}); + } + }, [itemList, validated]); + const handleItemChange = ( index: number, partial: { value?: any[]; lora_name?: string } @@ -93,11 +115,42 @@ const ModelLoraList = () => { }; return ( - name="lora_list"> + + name="lora_list" + rules={[ + { + validator: async (_, value: LoraListItem[]) => { + if (!validated) { + setValidated(true); + } + if (!value || value.length === 0) return; + + for (const it of value) { + const hasRepo = !!it.lora_repo_name?.trim(); + const hasName = !!it.lora_name?.trim(); + if (hasRepo !== hasName) { + throw new Error( + intl.formatMessage({ id: 'models.form.lora.rule.empty' }) + ); + } + } + + const names = value + .map((it) => it.lora_name?.trim()) + .filter(Boolean) as string[]; + if (names.length !== new Set(names).size) { + throw new Error( + intl.formatMessage({ id: 'models.form.lora.rule.duplicate' }) + ); + } + } + } + ]} + > @@ -107,6 +160,8 @@ const ModelLoraList = () => { base={base} defaultDataList={defaultDataList} selectedRepoNames={selectedRepoNames} + duplicateNames={duplicateNames} + validated={validated} onChange={(partial) => handleItemChange(index, partial)} /> )} diff --git a/src/pages/llmodels/services/use-query-lora-list.ts b/src/pages/llmodels/services/use-query-lora-list.ts index d6fe92ec..75ddaecf 100644 --- a/src/pages/llmodels/services/use-query-lora-list.ts +++ b/src/pages/llmodels/services/use-query-lora-list.ts @@ -46,7 +46,10 @@ export const useQueryModelLoraList = () => { const [dataList, setDataList] = useState([]); const getData = (params: Parameters) => { - fetchData(params).then((result) => { + fetchData({ + ...params, + limit: params.limit || 40 + }).then((result) => { if (result) { const groups: Record = {}; result.lora_list.forEach((item) => { diff --git a/src/pages/llmodels/style/lora-selection.less b/src/pages/llmodels/style/lora-selection.less new file mode 100644 index 00000000..0df3e40c --- /dev/null +++ b/src/pages/llmodels/style/lora-selection.less @@ -0,0 +1,26 @@ +.item { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + flex: 1; + :global { + .ant-select-dropdown { + > div { + .ant-cascader-menus { + display: grid; + grid-template-columns: 140px 1fr; + } + } + } + } + + > *:nth-child(1) { + flex: 1 1 320px; + min-width: 0; + } + > *:nth-child(2) { + flex: 1 1 120px; + min-width: 0; + } +}