Files
3x-ui/frontend/src/pages/xray/routing/RuleFormModal.tsx
T
Sentiago 1bf078c51e feat(routing): add panel-only comment field to routing rules (#6361)
Can annotate rules with a human-readable note for easier management.
The comment is stripped before sending the config to xray-core (same
pattern as the existing 'enabled' flag).

Backend: stripDisabledRules now removes 'comment' from generated config.
Frontend: input field in RuleFormModal, column in desktop table, chip
with tooltip in mobile card list. Schema and type definitions updated.
2026-09-02 20:22:50 +02:00

377 lines
12 KiB
TypeScript

import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { GeoTokenInput } from '@/components/geodata';
import { FormField } from '@/components/form/rhf';
import { useClientOptions } from '@/api/queries/useClientOptions';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
export interface RoutingRule {
enabled?: boolean;
comment?: string;
type?: string;
domain?: string | string[];
ip?: string | string[];
port?: string;
sourcePort?: string;
vlessRoute?: string;
network?: string;
sourceIP?: string | string[];
user?: string | string[];
inboundTag?: string[];
protocol?: string[];
attrs?: Record<string, string>;
outboundTag?: string;
balancerTag?: string;
[key: string]: unknown;
}
interface RuleFormModalProps {
open: boolean;
rule: RoutingRule | null;
inboundTags: string[];
outboundTags: string[];
balancerTags: string[];
onClose: () => void;
onConfirm: (rule: Record<string, unknown>) => void;
}
const initialForm = (): RuleFormValues => ({
enabled: true,
comment: '',
domain: '',
ip: '',
port: '',
sourcePort: '',
vlessRoute: '',
network: '',
sourceIP: '',
user: '',
inboundTag: [],
protocol: [],
attrs: [],
outboundTag: '',
balancerTag: '',
});
const NETWORKS = ['', 'tcp', 'udp', 'tcp,udp'];
const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
function csv(value: string): string[] {
if (!value) return [];
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
export default function RuleFormModal({
open,
rule,
inboundTags,
outboundTags,
balancerTags,
onClose,
onConfirm,
}: RuleFormModalProps) {
const { t } = useTranslation();
const methods = useForm<RuleFormValues>({ defaultValues: initialForm() });
const isEdit = rule != null;
const { data: inboundOptions } = useInboundOptions();
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
const {
data: clientEmails = [],
isFetching: clientsLoading,
isError: clientsError,
} = useClientOptions(open);
const user = useWatch({ control: methods.control, name: 'user' }) ?? '';
const selectedUsers = useMemo(() => csv(user), [user]);
const userOptions = useMemo(
() =>
[...new Set([...clientEmails, ...selectedUsers])].map((email) => ({
value: email,
label: email,
})),
[clientEmails, selectedUsers],
);
useEffect(() => {
if (!open) return;
if (rule) {
methods.reset({
enabled: rule.enabled !== false,
comment: rule.comment || '',
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
port: rule.port || '',
sourcePort: rule.sourcePort || '',
vlessRoute: rule.vlessRoute || '',
network: rule.network || '',
sourceIP: Array.isArray(rule.sourceIP) ? rule.sourceIP.join(',') : rule.sourceIP || '',
user: Array.isArray(rule.user) ? rule.user.join(',') : rule.user || '',
inboundTag: rule.inboundTag || [],
protocol: rule.protocol || [],
attrs: rule.attrs ? Object.entries(rule.attrs) : [],
outboundTag: rule.outboundTag || '',
balancerTag: rule.balancerTag || '',
});
} else {
methods.reset(initialForm());
}
}, [open, rule, methods]);
const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
function submit() {
const validated = RuleFormSchema.safeParse(methods.getValues());
if (!validated.success) return;
const v = validated.data;
const built: Record<string, unknown> = {
type: 'field',
enabled: v.enabled,
comment: v.comment,
domain: csv(v.domain),
ip: csv(v.ip),
port: v.port,
sourcePort: v.sourcePort,
vlessRoute: v.vlessRoute,
network: v.network,
sourceIP: csv(v.sourceIP),
user: csv(v.user),
inboundTag: v.inboundTag,
protocol: v.protocol,
attrs: Object.fromEntries(v.attrs.filter(([k]) => k)),
outboundTag: v.outboundTag === '' ? undefined : v.outboundTag,
balancerTag: v.balancerTag === '' ? undefined : v.balancerTag,
};
const managedKeys = new Set(Object.keys(built));
const out: Record<string, unknown> = {};
if (rule) {
for (const [key, value] of Object.entries(rule)) {
if (!managedKeys.has(key) && value !== undefined) out[key] = value;
}
}
for (const [k, v] of Object.entries(built)) {
if (v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
if (v === '') continue;
out[k] = v;
}
onConfirm(out);
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Routings')}`
: `+ ${t('pages.xray.Routings')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={640}
onOk={submit}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<FormField name="enabled" label={t('enable')} valueProp="checked">
<Switch disabled={isApiRule(rule ?? {})} />
</FormField>
<FormField name="comment" label={t('comment')}>
<Input maxLength={200} showCount placeholder={t('comment')} />
</FormField>
<FormField
name="sourceIP"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<FormField
name="sourcePort"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<FormField
name="vlessRoute"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<FormField name="network" label={t('pages.inbounds.network')}>
<Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
</FormField>
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
</FormField>
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button
size="small"
aria-label={t('add')}
icon={<PlusOutlined />}
onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
/>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{attrs.map((attr, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
aria-label={t('pages.nodes.name')}
placeholder={t('pages.nodes.name')}
onChange={(e) => {
const next = attrs.map((a, i) =>
i === idx ? ([e.target.value, a[1]] as [string, string]) : a,
);
methods.setValue('attrs', next);
}}
/>
<Input
value={attr[1]}
aria-label={t('pages.xray.ruleForm.value')}
placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => {
const next = attrs.map((a, i) =>
i === idx ? ([a[0], e.target.value] as [string, string]) : a,
);
methods.setValue('attrs', next);
}}
/>
<Button
aria-label={t('remove')}
icon={<MinusOutlined />}
onClick={() =>
methods.setValue(
'attrs',
attrs.filter((_, i) => i !== idx),
)
}
/>
</Space.Compact>
))}
</Form.Item>
<FormField
name="ip"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<FormField
name="domain"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<GeoTokenInput kind="domain" placeholder="google.com, geosite:cn" />
</FormField>
<FormField
name="user"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
transform={{
input: (value) => csv(typeof value === 'string' ? value : ''),
output: (value) => (Array.isArray(value) ? value.join(',') : ''),
}}
>
<Select
mode="tags"
tokenSeparators={[',']}
allowClear
loading={clientsLoading}
placeholder={t('pages.xray.ruleForm.userPlaceholder')}
showSearch={{ optionFilterProp: 'label' }}
notFoundContent={
clientsLoading
? t('loading')
: clientsError
? t('pages.xray.ruleForm.userLoadError')
: t('pages.xray.ruleForm.userEmpty')
}
options={userOptions}
/>
</FormField>
<FormField
name="port"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
options={inboundTags.map((tag) => ({
value: tag,
label: formatInboundTag(tag, remarkByTag),
}))}
/>
</FormField>
<FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
<Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
</FormField>
<FormField
name="balancerTag"
label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}