fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel
This commit is contained in:
YoVinchen
2026-01-11 01:33:41 +08:00
parent 5694353798
commit e002bdba25
4 changed files with 162 additions and 138 deletions
@@ -21,52 +21,64 @@ export function AutoFailoverConfigPanel({
const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateAppProxyConfig();
// 使用字符串状态以支持完全清空数字输入框
const [formData, setFormData] = useState({
autoFailoverEnabled: false,
maxRetries: 3,
streamingFirstByteTimeout: 30,
streamingIdleTimeout: 60,
nonStreamingTimeout: 300,
circuitFailureThreshold: 5,
circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60,
circuitErrorRateThreshold: 0.5,
circuitMinRequests: 10,
maxRetries: "3",
streamingFirstByteTimeout: "30",
streamingIdleTimeout: "60",
nonStreamingTimeout: "300",
circuitFailureThreshold: "5",
circuitSuccessThreshold: "2",
circuitTimeoutSeconds: "60",
circuitErrorRateThreshold: "50", // 存储百分比值
circuitMinRequests: "10",
});
useEffect(() => {
if (config) {
setFormData({
autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: String(
Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
});
}
}, [config]);
const handleSave = async () => {
if (!config) return;
// 解析数字,空值使用默认值,0 是有效值
const parseNum = (val: string, defaultVal: number) => {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
try {
await updateConfig.mutateAsync({
appType,
enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled,
maxRetries: formData.maxRetries,
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
streamingIdleTimeout: formData.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
circuitMinRequests: formData.circuitMinRequests,
maxRetries: parseNum(formData.maxRetries, 3),
streamingFirstByteTimeout: parseNum(
formData.streamingFirstByteTimeout,
30,
),
streamingIdleTimeout: parseNum(formData.streamingIdleTimeout, 60),
nonStreamingTimeout: parseNum(formData.nonStreamingTimeout, 300),
circuitFailureThreshold: parseNum(formData.circuitFailureThreshold, 5),
circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold, 2),
circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds, 60),
circuitErrorRateThreshold:
parseNum(formData.circuitErrorRateThreshold, 50) / 100,
circuitMinRequests: parseNum(formData.circuitMinRequests, 10),
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -83,15 +95,17 @@ export function AutoFailoverConfigPanel({
if (config) {
setFormData({
autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: String(
Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
});
}
};
@@ -142,13 +156,9 @@ export function AutoFailoverConfigPanel({
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
maxRetries: isNaN(val) ? 0 : val,
});
}}
onChange={(e) =>
setFormData({ ...formData, maxRetries: e.target.value })
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -169,13 +179,12 @@ export function AutoFailoverConfigPanel({
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
circuitFailureThreshold: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -208,13 +217,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
});
}}
streamingFirstByteTimeout: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -235,13 +243,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: isNaN(val) ? 0 : val,
});
}}
streamingIdleTimeout: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -262,13 +269,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: isNaN(val) ? 0 : val,
});
}}
nonStreamingTimeout: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -298,13 +304,12 @@ export function AutoFailoverConfigPanel({
min="1"
max="10"
value={formData.circuitSuccessThreshold}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
circuitSuccessThreshold: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -325,13 +330,12 @@ export function AutoFailoverConfigPanel({
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
});
}}
circuitTimeoutSeconds: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -352,14 +356,13 @@ export function AutoFailoverConfigPanel({
min="0"
max="100"
step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) => {
const val = parseInt(e.target.value);
value={formData.circuitErrorRateThreshold}
onChange={(e) =>
setFormData({
...formData,
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
});
}}
circuitErrorRateThreshold: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -380,13 +383,12 @@ export function AutoFailoverConfigPanel({
min="5"
max="100"
value={formData.circuitMinRequests}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
});
}}
circuitMinRequests: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -16,24 +16,43 @@ export function CircuitBreakerConfigPanel() {
const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
// 使用字符串状态以支持完全清空输入框
const [formData, setFormData] = useState({
failureThreshold: 5,
successThreshold: 2,
timeoutSeconds: 60,
errorRateThreshold: 0.5,
minRequests: 10,
failureThreshold: "5",
successThreshold: "2",
timeoutSeconds: "60",
errorRateThreshold: "50", // 存储百分比值
minRequests: "10",
});
// 当配置加载完成时更新表单数据
useEffect(() => {
if (config) {
setFormData(config);
setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
}
}, [config]);
const handleSave = async () => {
// 解析数字,空值使用默认值,0 是有效值
const parseNum = (val: string, defaultVal: number) => {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
try {
await updateConfig.mutateAsync(formData);
const parsed = {
failureThreshold: parseNum(formData.failureThreshold, 5),
successThreshold: parseNum(formData.successThreshold, 2),
timeoutSeconds: parseNum(formData.timeoutSeconds, 60),
errorRateThreshold: parseNum(formData.errorRateThreshold, 50) / 100,
minRequests: parseNum(formData.minRequests, 10),
};
await updateConfig.mutateAsync(parsed);
toast.success("熔断器配置已保存", { closeButton: true });
} catch (error) {
toast.error("保存失败: " + String(error));
@@ -42,7 +61,13 @@ export function CircuitBreakerConfigPanel() {
const handleReset = () => {
if (config) {
setFormData(config);
setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
}
};
@@ -72,10 +97,7 @@ export function CircuitBreakerConfigPanel() {
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
setFormData({ ...formData, failureThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -93,10 +115,7 @@ export function CircuitBreakerConfigPanel() {
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
setFormData({ ...formData, timeoutSeconds: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -114,10 +133,7 @@ export function CircuitBreakerConfigPanel() {
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
setFormData({ ...formData, successThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -134,12 +150,9 @@ export function CircuitBreakerConfigPanel() {
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
value={formData.errorRateThreshold}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
setFormData({ ...formData, errorRateThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -157,10 +170,7 @@ export function CircuitBreakerConfigPanel() {
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
})
setFormData({ ...formData, minRequests: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
+7 -7
View File
@@ -38,15 +38,15 @@ export function ProxyPanel() {
const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态
// 监听地址/端口的本地状态(端口用字符串以支持完全清空)
const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(15721);
const [listenPort, setListenPort] = useState("15721");
// 同步全局配置到本地状态
useEffect(() => {
if (globalConfig) {
setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort);
setListenPort(String(globalConfig.listenPort));
}
}, [globalConfig]);
@@ -102,11 +102,13 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
const port = parseInt(listenPort);
const validPort = isNaN(port) || port < 1024 || port > 65535 ? 15721 : port;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort,
listenPort: validPort,
});
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
@@ -414,9 +416,7 @@ export function ProxyPanel() {
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 15721)
}
onChange={(e) => setListenPort(e.target.value)}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{
+30 -18
View File
@@ -17,10 +17,11 @@ export function ModelTestConfigPanel() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<StreamCheckConfig>({
timeoutSecs: 45,
maxRetries: 2,
degradedThresholdMs: 6000,
// 使用字符串状态以支持完全清空数字输入框
const [config, setConfig] = useState({
timeoutSecs: "45",
maxRetries: "2",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview",
@@ -35,7 +36,14 @@ export function ModelTestConfigPanel() {
setIsLoading(true);
setError(null);
const data = await getStreamCheckConfig();
setConfig(data);
setConfig({
timeoutSecs: String(data.timeoutSecs),
maxRetries: String(data.maxRetries),
degradedThresholdMs: String(data.degradedThresholdMs),
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
});
} catch (e) {
setError(String(e));
} finally {
@@ -44,9 +52,22 @@ export function ModelTestConfigPanel() {
}
async function handleSave() {
// 解析数字,空值使用默认值,0 是有效值
const parseNum = (val: string, defaultVal: number) => {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
try {
setIsSaving(true);
await saveStreamCheckConfig(config);
const parsed: StreamCheckConfig = {
timeoutSecs: parseNum(config.timeoutSecs, 45),
maxRetries: parseNum(config.maxRetries, 2),
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
closeButton: true,
});
@@ -132,10 +153,7 @@ export function ModelTestConfigPanel() {
max={120}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({
...config,
timeoutSecs: parseInt(e.target.value) || 45,
})
setConfig({ ...config, timeoutSecs: e.target.value })
}
/>
</div>
@@ -149,10 +167,7 @@ export function ModelTestConfigPanel() {
max={5}
value={config.maxRetries}
onChange={(e) =>
setConfig({
...config,
maxRetries: parseInt(e.target.value) || 2,
})
setConfig({ ...config, maxRetries: e.target.value })
}
/>
</div>
@@ -169,10 +184,7 @@ export function ModelTestConfigPanel() {
step={1000}
value={config.degradedThresholdMs}
onChange={(e) =>
setConfig({
...config,
degradedThresholdMs: parseInt(e.target.value) || 6000,
})
setConfig({ ...config, degradedThresholdMs: e.target.value })
}
/>
</div>