From b1f05e8cd73d846633060b4df9c6adb747d8d976 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 7 Mar 2026 14:03:53 +0800 Subject: [PATCH] fix(config): validate visual numeric fields --- src/components/config/VisualConfigEditor.tsx | 30 +++++++++++++- src/hooks/useVisualConfig.ts | 43 +++++++++++++++++++- src/i18n/locales/en.json | 5 +++ src/i18n/locales/ru.json | 5 +++ src/i18n/locales/zh-CN.json | 5 +++ src/pages/ConfigPage.tsx | 26 ++++++++++-- src/types/visualConfig.ts | 15 +++++++ 7 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index 7dd6714..510b35a 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -15,6 +15,8 @@ import type { PayloadParamEntry, PayloadParamValueType, PayloadRule, + VisualConfigValidationErrorCode, + VisualConfigValidationErrors, VisualConfigValues, } from '@/types/visualConfig'; import { makeClientId } from '@/types/visualConfig'; @@ -27,10 +29,19 @@ import { isValidApiKeyCharset } from '@/utils/validation'; interface VisualConfigEditorProps { values: VisualConfigValues; + validationErrors?: VisualConfigValidationErrors; disabled?: boolean; onChange: (values: Partial) => void; } +function getValidationMessage( + t: ReturnType['t'], + errorCode?: VisualConfigValidationErrorCode +) { + if (!errorCode) return undefined; + return t(`config_management.visual.validation.${errorCode}`); +} + type ToggleRowProps = { title: string; description?: string; @@ -717,11 +728,21 @@ function PayloadFilterRulesEditor({ ); } -export function VisualConfigEditor({ values, disabled = false, onChange }: VisualConfigEditorProps) { +export function VisualConfigEditor({ values, validationErrors, disabled = false, onChange }: VisualConfigEditorProps) { const { t } = useTranslation(); const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; const isNonstreamKeepaliveDisabled = values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0'; + const portError = getValidationMessage(t, validationErrors?.port); + const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb); + const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry); + const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval); + const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']); + const bootstrapRetriesError = getValidationMessage(t, validationErrors?.['streaming.bootstrapRetries']); + const nonstreamKeepaliveError = getValidationMessage( + t, + validationErrors?.['streaming.nonstreamKeepaliveInterval'] + ); return (
@@ -741,6 +762,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua value={values.port} onChange={(e) => onChange({ port: e.target.value })} disabled={disabled} + error={portError} /> @@ -873,6 +895,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua value={values.logsMaxTotalSizeMb} onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })} disabled={disabled} + error={logsMaxSizeError} />
@@ -895,6 +918,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua value={values.requestRetry} onChange={(e) => onChange({ requestRetry: e.target.value })} disabled={disabled} + error={requestRetryError} /> onChange({ maxRetryInterval: e.target.value })} disabled={disabled} + error={maxRetryIntervalError} />
@@ -993,6 +1018,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua )}
+ {keepaliveError &&
{keepaliveError}
}
{t('config_management.visual.sections.streaming.keepalive_hint')}
onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })} disabled={disabled} hint={t('config_management.visual.sections.streaming.bootstrap_hint')} + error={bootstrapRetriesError} /> @@ -1041,6 +1068,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua )} + {nonstreamKeepaliveError &&
{nonstreamKeepaliveError}
}
{t('config_management.visual.sections.streaming.nonstream_keepalive_hint')}
diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts index f3d1f26..7705a49 100644 --- a/src/hooks/useVisualConfig.ts +++ b/src/hooks/useVisualConfig.ts @@ -5,6 +5,7 @@ import type { PayloadParamValueType, PayloadRule, VisualConfigValues, + VisualConfigValidationErrors, } from '@/types/visualConfig'; import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; @@ -93,13 +94,46 @@ function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown return; } - const parsed = Number.parseInt(trimmed, 10); + if (!/^-?\d+$/.test(trimmed)) { + return; + } + + const parsed = Number(trimmed); if (Number.isFinite(parsed)) { doc.setIn(path, parsed); return; } +} - if (docHas(doc, path)) doc.deleteIn(path); +function getNonNegativeIntegerError(value: string): 'non_negative_integer' | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!/^-?\d+$/.test(trimmed)) return 'non_negative_integer'; + return Number(trimmed) >= 0 ? undefined : 'non_negative_integer'; +} + +function getPortError(value: string): 'port_range' | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!/^\d+$/.test(trimmed)) return 'port_range'; + const parsed = Number(trimmed); + return parsed >= 1 && parsed <= 65535 ? undefined : 'port_range'; +} + +export function getVisualConfigValidationErrors( + values: VisualConfigValues +): VisualConfigValidationErrors { + return { + port: getPortError(values.port), + logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb), + requestRetry: getNonNegativeIntegerError(values.requestRetry), + maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval), + 'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds), + 'streaming.bootstrapRetries': getNonNegativeIntegerError(values.streaming.bootstrapRetries), + 'streaming.nonstreamKeepaliveInterval': getNonNegativeIntegerError( + values.streaming.nonstreamKeepaliveInterval + ), + }; } function deepClone(value: T): T { @@ -277,6 +311,10 @@ export function useVisualConfig() { ...DEFAULT_VISUAL_VALUES, }); const [visualParseError, setVisualParseError] = useState(null); + const visualValidationErrors = useMemo( + () => getVisualConfigValidationErrors(visualValues), + [visualValues] + ); const visualDirty = useMemo(() => { return JSON.stringify(visualValues) !== JSON.stringify(baselineValues); @@ -536,6 +574,7 @@ export function useVisualConfig() { visualValues, visualDirty, visualParseError, + visualValidationErrors, loadVisualValuesFromYaml, applyVisualChangesToYaml, setVisualValues, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5b4eddf..ae63da9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1238,6 +1238,11 @@ "value_json": "JSON value", "value_default": "Value" }, + "validation": { + "validation_blocked": "Fix validation errors before saving", + "port_range": "Enter a valid port between 1 and 65535", + "non_negative_integer": "Enter a non-negative whole number" + }, "common": { "edit": "Edit", "delete": "Delete", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a414c72..f14a1db 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1243,6 +1243,11 @@ "value_json": "Значение JSON", "value_default": "Значение" }, + "validation": { + "validation_blocked": "Исправьте ошибки валидации перед сохранением", + "port_range": "Введите корректный порт от 1 до 65535", + "non_negative_integer": "Введите неотрицательное целое число" + }, "common": { "edit": "Изменить", "delete": "Удалить", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5ce2fa9..9a24fcf 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1238,6 +1238,11 @@ "value_json": "JSON 值", "value_default": "值" }, + "validation": { + "validation_blocked": "请先修复表单校验错误再保存", + "port_range": "请输入 1 到 65535 之间的有效端口", + "non_negative_integer": "请输入非负整数" + }, "common": { "edit": "编辑", "delete": "删除", diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index a57c157..27cc329 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -40,6 +40,7 @@ export function ConfigPage() { visualValues, visualDirty, visualParseError, + visualValidationErrors, loadVisualValuesFromYaml, applyVisualChangesToYaml, setVisualValues @@ -72,6 +73,8 @@ export function ConfigPage() { const disableControls = connectionStatus !== 'connected'; const isDirty = dirty || visualDirty; const hasVisualModeError = !!visualParseError; + const hasVisualValidationErrors = + activeTab === 'visual' && Object.values(visualValidationErrors).some(Boolean); const loadConfig = useCallback(async () => { setLoading(true); @@ -388,15 +391,23 @@ export function ConfigPage() { if (loading) return t('config_management.status_loading'); if (error) return t('config_management.status_load_failed'); if (hasVisualModeError) return t('config_management.visual_mode_unavailable'); + if (hasVisualValidationErrors) return t('config_management.visual.validation.validation_blocked'); if (saving) return t('config_management.status_saving'); if (isDirty) return t('config_management.status_dirty'); return t('config_management.status_loaded'); }; - const isLoadedStatus = !disableControls && !loading && !error && !saving && !isDirty && !hasVisualModeError; + const isLoadedStatus = + !disableControls && + !loading && + !error && + !saving && + !isDirty && + !hasVisualModeError && + !hasVisualValidationErrors; const getStatusClass = () => { - if (error || hasVisualModeError) return styles.error; + if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error; if (isDirty) return styles.modified; if (!loading && !saving) return styles.saved; return ''; @@ -438,7 +449,15 @@ export function ConfigPage() { type="button" className={styles.floatingActionButton} onClick={handleSave} - disabled={disableControls || loading || saving || !isDirty || diffModalOpen || hasVisualModeError} + disabled={ + disableControls || + loading || + saving || + !isDirty || + diffModalOpen || + hasVisualModeError || + hasVisualValidationErrors + } title={t('config_management.save')} aria-label={t('config_management.save')} > @@ -485,6 +504,7 @@ export function ConfigPage() { {activeTab === 'visual' ? ( diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index 5aa91fe..fb01a50 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -1,5 +1,20 @@ export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json'; +export type VisualConfigFieldPath = + | 'port' + | 'logsMaxTotalSizeMb' + | 'requestRetry' + | 'maxRetryInterval' + | 'streaming.keepaliveSeconds' + | 'streaming.bootstrapRetries' + | 'streaming.nonstreamKeepaliveInterval'; + +export type VisualConfigValidationErrorCode = 'port_range' | 'non_negative_integer'; + +export type VisualConfigValidationErrors = Partial< + Record +>; + export type PayloadParamEntry = { id: string; path: string;