@@ -179,7 +180,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
updateSetting({ sessionMaxAge: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
updateSetting({ pageSize: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
@@ -234,11 +235,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<>
updateSetting({ expireDiff: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
updateSetting({ trafficDiff: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
>
),
@@ -308,7 +309,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
{ if (v != null) updateSetting({ ldapPort: v }); }} />
+ onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
updateSetting({ ldapUseTLS: v })} />
@@ -387,15 +388,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
+ onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
>
),
diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx
index 403a91227..eebb759d5 100644
--- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx
+++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx
@@ -17,6 +17,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
+import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
setMuxField('concurrency', Number(v) || 0)} />
+ onChange={onNumber((v) => setMuxField('concurrency', v))} />
setMuxField('xudpConcurrency', Number(v) || 0)} />
+ onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
diff --git a/frontend/src/pages/xray/dns/DnsTab.tsx b/frontend/src/pages/xray/dns/DnsTab.tsx
index 5d5cc3ebc..0eb8c1dbe 100644
--- a/frontend/src/pages/xray/dns/DnsTab.tsx
+++ b/frontend/src/pages/xray/dns/DnsTab.tsx
@@ -11,6 +11,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
+import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -311,7 +312,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
min={0}
step={60}
style={{ width: '100%' }}
- onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
+ onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
diff --git a/frontend/src/pages/xray/dns/useDnsColumns.tsx b/frontend/src/pages/xray/dns/useDnsColumns.tsx
index b523bc2a6..8244010a0 100644
--- a/frontend/src/pages/xray/dns/useDnsColumns.tsx
+++ b/frontend/src/pages/xray/dns/useDnsColumns.tsx
@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
+import { onNumber } from '@/utils/onNumber';
+
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
@@ -113,7 +115,7 @@ export function useFakednsColumns({
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
- onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
+ onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
/>
),
},
diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx
index f4e15bcb0..b29d05e9f 100644
--- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx
+++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx
@@ -38,6 +38,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
@@ -626,14 +627,14 @@ export default function OutboundsTab({
setIntervalHM(Number(v) || 0, intervalMinutes)}
+ onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.hours')}
setIntervalHM(intervalHours, Number(v) || 0)}
+ onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.minutes')}
diff --git a/frontend/src/test/general-tab.test.tsx b/frontend/src/test/general-tab.test.tsx
new file mode 100644
index 000000000..d2895be19
--- /dev/null
+++ b/frontend/src/test/general-tab.test.tsx
@@ -0,0 +1,40 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import GeneralTab from '@/pages/settings/GeneralTab';
+import { renderWithProviders } from './test-utils';
+
+describe('GeneralTab', () => {
+ it('keeps the stored page size when the field is cleared', () => {
+ const updateSetting = vi.fn();
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ const pageSizeInput = screen.getByDisplayValue('25');
+ fireEvent.change(pageSizeInput, { target: { value: '' } });
+ fireEvent.blur(pageSizeInput);
+
+ expect(updateSetting).not.toHaveBeenCalled();
+ expect((pageSizeInput as HTMLInputElement).value).toBe('25');
+ });
+
+ it('forwards typed page sizes unchanged, zero included', () => {
+ const updateSetting = vi.fn();
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
+
+ expect(updateSetting).toHaveBeenCalledWith({ pageSize: 0 });
+ });
+});
diff --git a/frontend/src/test/on-number.test.ts b/frontend/src/test/on-number.test.ts
new file mode 100644
index 000000000..a0d32219d
--- /dev/null
+++ b/frontend/src/test/on-number.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { onNumber } from '@/utils/onNumber';
+
+describe('onNumber', () => {
+ it('forwards numeric values, including zero and negatives', () => {
+ const apply = vi.fn();
+ const handler = onNumber(apply);
+ handler(8443);
+ handler(0);
+ handler(-1);
+ expect(apply.mock.calls).toEqual([[8443], [0], [-1]]);
+ });
+
+ it('ignores cleared events instead of writing a synthetic value', () => {
+ const apply = vi.fn();
+ const handler = onNumber(apply);
+ handler(null);
+ handler(undefined);
+ handler(NaN);
+ expect(apply).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/test/subscription-general-tab.test.tsx b/frontend/src/test/subscription-general-tab.test.tsx
index fbc4914a5..769cdae00 100644
--- a/frontend/src/test/subscription-general-tab.test.tsx
+++ b/frontend/src/test/subscription-general-tab.test.tsx
@@ -26,6 +26,7 @@ describe('SubscriptionGeneralTab', () => {
fireEvent.blur(portInput);
expect(updateSetting).not.toHaveBeenCalled();
+ expect((portInput as HTMLInputElement).value).toBe('2096');
});
it('forwards typed subscription ports unchanged', () => {
diff --git a/frontend/src/utils/onNumber.ts b/frontend/src/utils/onNumber.ts
new file mode 100644
index 000000000..77e1c4b6e
--- /dev/null
+++ b/frontend/src/utils/onNumber.ts
@@ -0,0 +1,16 @@
+/**
+ * Wraps an Ant Design InputNumber change handler with the shared
+ * cleared-field semantic: null and undefined change events (a cleared or
+ * unparsable field) are ignored, leaving the stored value in place — the
+ * input snaps back on blur — while real numbers pass through unchanged.
+ * Number-only by design: not for `stringMode` inputs, whose whole point is
+ * to avoid the IEEE-754 round trip this signature would force.
+ */
+export function onNumber(
+ apply: (value: number) => void,
+): (value: number | null | undefined) => void {
+ return (value) => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return;
+ apply(value);
+ };
+}