|
@@ -127,6 +127,42 @@ function isValidShareAddrInput(value: string): boolean {
|
|
|
return SHARE_ADDR_HOSTNAME_RE.test(v);
|
|
return SHARE_ADDR_HOSTNAME_RE.test(v);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface RhfValidationIssue {
|
|
|
|
|
+ path: PropertyKey[];
|
|
|
|
|
+ message: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function firstRhfValidationIssue(
|
|
|
|
|
+ value: unknown,
|
|
|
|
|
+ path: PropertyKey[] = [],
|
|
|
|
|
+): RhfValidationIssue | null {
|
|
|
|
|
+ if (!value || typeof value !== 'object') return null;
|
|
|
|
|
+ const record = value as Record<string, unknown>;
|
|
|
|
|
+ // `type` is what marks a react-hook-form leaf FieldError; anything else is a group.
|
|
|
|
|
+ if ('type' in record) {
|
|
|
|
|
+ return { path, message: typeof record.message === 'string' ? record.message : '' };
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const key of Object.keys(record)) {
|
|
|
|
|
+ const issue = firstRhfValidationIssue(record[key], [...path, key]);
|
|
|
|
|
+ if (issue) return issue;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function tabForValidationPath(path: PropertyKey[]): string {
|
|
|
|
|
+ if (path[0] === 'settings') return 'protocol';
|
|
|
|
|
+ if (path[0] === 'sniffing') return 'sniffing';
|
|
|
|
|
+ if (path[0] === 'streamSettings') {
|
|
|
|
|
+ if (
|
|
|
|
|
+ path[1] === 'security'
|
|
|
|
|
+ || path[1] === 'realitySettings'
|
|
|
|
|
+ || path[1] === 'tlsSettings'
|
|
|
|
|
+ ) return 'security';
|
|
|
|
|
+ return 'stream';
|
|
|
|
|
+ }
|
|
|
|
|
+ return 'basic';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
interface InboundFormModalProps {
|
|
interface InboundFormModalProps {
|
|
|
open: boolean;
|
|
open: boolean;
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
@@ -195,6 +231,7 @@ export default function InboundFormModal({
|
|
|
const [saving, setSaving] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
const [scanning, setScanning] = useState(false);
|
|
const [scanning, setScanning] = useState(false);
|
|
|
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
|
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
|
|
|
|
+ const [activeTab, setActiveTab] = useState('basic');
|
|
|
const {
|
|
const {
|
|
|
fallbacks,
|
|
fallbacks,
|
|
|
fallbackChildOptions,
|
|
fallbackChildOptions,
|
|
@@ -356,6 +393,7 @@ export default function InboundFormModal({
|
|
|
: buildAddModeValues();
|
|
: buildAddModeValues();
|
|
|
methods.reset(initial);
|
|
methods.reset(initial);
|
|
|
setScanResult(null);
|
|
setScanResult(null);
|
|
|
|
|
+ setActiveTab('basic');
|
|
|
const initialTag = (initial.tag ?? '') as string;
|
|
const initialTag = (initial.tag ?? '') as string;
|
|
|
autoTagRef.current = isAutoInboundTag(initialTag, {
|
|
autoTagRef.current = isAutoInboundTag(initialTag, {
|
|
|
port: initial.port ?? 0,
|
|
port: initial.port ?? 0,
|
|
@@ -460,8 +498,7 @@ export default function InboundFormModal({
|
|
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
|
}, [mode, methods]);
|
|
}, [mode, methods]);
|
|
|
|
|
|
|
|
- const submit = async () => {
|
|
|
|
|
- if (!(await methods.trigger())) return;
|
|
|
|
|
|
|
+ const saveValues = async () => {
|
|
|
/*
|
|
/*
|
|
|
* getValues() returns the entire form store, including settings.clients and
|
|
* getValues() returns the entire form store, including settings.clients and
|
|
|
* settings.fallbacks which have no bound field (clients are managed via the
|
|
* settings.fallbacks which have no bound field (clients are managed via the
|
|
@@ -503,6 +540,17 @@ export default function InboundFormModal({
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * Field errors render inline, but every tab is force-rendered, so an error on
|
|
|
|
|
+ * a hidden tab looks like a dead Save button — jump to it and say what broke.
|
|
|
|
|
+ */
|
|
|
|
|
+ const submit = methods.handleSubmit(saveValues, (errors) => {
|
|
|
|
|
+ const issue = firstRhfValidationIssue(errors);
|
|
|
|
|
+ if (!issue) return;
|
|
|
|
|
+ setActiveTab(tabForValidationPath(issue.path));
|
|
|
|
|
+ messageApi.error(formatInboundIssue(issue, methods.getValues(), t));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
const title = mode === 'edit'
|
|
const title = mode === 'edit'
|
|
|
? t('pages.inbounds.modifyInbound')
|
|
? t('pages.inbounds.modifyInbound')
|
|
|
: t('pages.inbounds.addInbound');
|
|
: t('pages.inbounds.addInbound');
|
|
@@ -961,7 +1009,7 @@ export default function InboundFormModal({
|
|
|
wrapperCol={{ sm: { span: 14 } }}
|
|
wrapperCol={{ sm: { span: 14 } }}
|
|
|
labelWrap
|
|
labelWrap
|
|
|
>
|
|
>
|
|
|
- <Tabs items={[
|
|
|
|
|
|
|
+ <Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
|
|
{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
|
|
{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
|
|
|
...(([
|
|
...(([
|
|
|
Protocols.VLESS,
|
|
Protocols.VLESS,
|