fix(config): validate visual numeric fields

This commit is contained in:
Supra4E8C
2026-03-07 14:03:53 +08:00
parent 25598dd219
commit b1f05e8cd7
7 changed files with 123 additions and 6 deletions
+29 -1
View File
@@ -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<VisualConfigValues>) => void;
}
function getValidationMessage(
t: ReturnType<typeof useTranslation>['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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -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}
/>
</SectionGrid>
</ConfigSection>
@@ -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}
/>
</SectionGrid>
</div>
@@ -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}
/>
<Input
label={t('config_management.visual.sections.network.max_retry_interval')}
@@ -903,6 +927,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
value={values.maxRetryInterval}
onChange={(e) => onChange({ maxRetryInterval: e.target.value })}
disabled={disabled}
error={maxRetryIntervalError}
/>
<div className="form-group">
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
@@ -993,6 +1018,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
</span>
)}
</div>
{keepaliveError && <div className="error-box">{keepaliveError}</div>}
<div className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
</div>
<Input
@@ -1003,6 +1029,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
onChange={(e) => onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })}
disabled={disabled}
hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
error={bootstrapRetriesError}
/>
</SectionGrid>
@@ -1041,6 +1068,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
</span>
)}
</div>
{nonstreamKeepaliveError && <div className="error-box">{nonstreamKeepaliveError}</div>}
<div className="hint">
{t('config_management.visual.sections.streaming.nonstream_keepalive_hint')}
</div>
+41 -2
View File
@@ -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<T>(value: T): T {
@@ -277,6 +311,10 @@ export function useVisualConfig() {
...DEFAULT_VISUAL_VALUES,
});
const [visualParseError, setVisualParseError] = useState<string | null>(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,
+5
View File
@@ -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",
+5
View File
@@ -1243,6 +1243,11 @@
"value_json": "Значение JSON",
"value_default": "Значение"
},
"validation": {
"validation_blocked": "Исправьте ошибки валидации перед сохранением",
"port_range": "Введите корректный порт от 1 до 65535",
"non_negative_integer": "Введите неотрицательное целое число"
},
"common": {
"edit": "Изменить",
"delete": "Удалить",
+5
View File
@@ -1238,6 +1238,11 @@
"value_json": "JSON 值",
"value_default": "值"
},
"validation": {
"validation_blocked": "请先修复表单校验错误再保存",
"port_range": "请输入 1 到 65535 之间的有效端口",
"non_negative_integer": "请输入非负整数"
},
"common": {
"edit": "编辑",
"delete": "删除",
+23 -3
View File
@@ -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' ? (
<VisualConfigEditor
values={visualValues}
validationErrors={visualValidationErrors}
disabled={disableControls || loading}
onChange={setVisualValues}
/>
+15
View File
@@ -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<VisualConfigFieldPath, VisualConfigValidationErrorCode>
>;
export type PayloadParamEntry = {
id: string;
path: string;