chore(ui): redesign Edit Routing Rules modal
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled

This commit is contained in:
fgsfds 2026-06-01 15:45:48 +05:00
parent 2a03844566
commit ba2baa9028
No known key found for this signature in database
GPG key ID: 264C1B9113012917
15 changed files with 282 additions and 174 deletions

View file

@ -0,0 +1,26 @@
import {useTranslation} from 'react-i18next';
import {Tooltip} from 'antd';
import {QuestionCircleOutlined} from '@ant-design/icons';
export function LabelWithTooltip({labelKey, tooltipKey}: {
labelKey: string;
tooltipKey: string;
}) {
const {t} = useTranslation();
return (
<Tooltip title={t(tooltipKey)}>
{t(labelKey)} <QuestionCircleOutlined/>
</Tooltip>
);
}
export function LabelWithOnePerLineTooltip({labelKey}: {
labelKey: string;
}) {
return <LabelWithTooltip
labelKey={labelKey}
tooltipKey="pages.xray.rules.onePerLine"
/>
}

View file

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import {type ChangeEvent, useEffect, useState} from 'react';
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd'; import {Button, Col, Form, Input, Modal, Row, Select, Space, Typography} from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import {PlusOutlined, MinusOutlined} from '@ant-design/icons';
import { InputAddon } from '@/components/ui'; import {InputAddon} from '@/components/ui';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; import {RuleFormSchema, type RuleFormValues} from '@/schemas/xray';
import {LabelWithOnePerLineTooltip, LabelWithTooltip} from "@/components/ui/TooltipsHelper";
export interface RoutingRule { export interface RoutingRule {
type?: string; type?: string;
@ -20,6 +21,7 @@ export interface RoutingRule {
attrs?: Record<string, string>; attrs?: Record<string, string>;
outboundTag?: string; outboundTag?: string;
balancerTag?: string; balancerTag?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@ -59,6 +61,30 @@ function csv(value: string): string[] {
return value.split(',').map((s) => s.trim()).filter(Boolean); return value.split(',').map((s) => s.trim()).filter(Boolean);
} }
const CommaSeparatedTextArea = ({value, onChange, placeholder}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) => {
const displayValue = value ? value.split(',').join('\n') : '';
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const commaSeparated = e.target.value
.split(/\r?\n/)
.join(',');
onChange(commaSeparated);
};
return (
<Input.TextArea
autoSize={{minRows: 2, maxRows: 10}}
value={displayValue}
onChange={handleChange}
placeholder={placeholder}
/>
);
};
export default function RuleFormModal({ export default function RuleFormModal({
open, open,
rule, rule,
@ -68,7 +94,7 @@ export default function RuleFormModal({
onClose, onClose,
onConfirm, onConfirm,
}: RuleFormModalProps) { }: RuleFormModalProps) {
const { t } = useTranslation(); const {t} = useTranslation();
const [form, setForm] = useState<FormState>(initialForm); const [form, setForm] = useState<FormState>(initialForm);
const isEdit = rule != null; const isEdit = rule != null;
@ -96,7 +122,7 @@ export default function RuleFormModal({
}, [open, rule]); }, [open, rule]);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({...prev, [key]: value}));
function submit() { function submit() {
const validated = RuleFormSchema.safeParse(form); const validated = RuleFormSchema.safeParse(form);
@ -134,69 +160,182 @@ export default function RuleFormModal({
: `+ ${t('pages.xray.Routings')}`; : `+ ${t('pages.xray.Routings')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create'); const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
const rowLayout = {gutter: 16};
const colLayout = {xs: 24, md: 8};
return ( return (
<Modal <Modal
open={open} open={open}
title={title} title={title}
okText={okText} okText={okText}
cancelText={t('close')} cancelText={t('close')}
mask={{ closable: false }} mask={{closable: false}}
width={640} width={1400}
onOk={submit} onOk={submit}
onCancel={onClose} onCancel={onClose}
style={{top: 20}}
styles={{
body: {
maxHeight: 'calc(100vh - 160px)',
overflowY: 'auto',
padding: '8px',
},
}}
> >
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}> <Form layout="vertical" colon={false}>
<Form.Item <Row {...rowLayout}>
label={ <Col {...colLayout}>
<Tooltip title={t('pages.xray.rules.useComma')}> <Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.sourceIps"/>}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined /> <CommaSeparatedTextArea
</Tooltip> value={form.sourceIP}
} onChange={(v) => update('sourceIP', v)}
> placeholder={"0.0.0.0/8\nfc00::/7\ngeoip:ir"}
<Input value={form.sourceIP} onChange={(e) => update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" /> />
</Form.Item> </Form.Item>
</Col>
<Form.Item <Col {...colLayout}>
label={ <Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.sourcePort"/>}>
<Tooltip title={t('pages.xray.rules.useComma')}> <CommaSeparatedTextArea
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined /> value={form.sourcePort}
</Tooltip> onChange={(v) => update('sourcePort', v)}
} placeholder={"53\n443\n1000-2000"}
> />
<Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item> </Form.Item>
</Col>
<Form.Item <Col {...colLayout}>
label={ <Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.vlessRoute"/>}>
<Tooltip title={t('pages.xray.rules.useComma')}> <CommaSeparatedTextArea
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined /> value={form.vlessRoute}
</Tooltip> onChange={(v) => update('vlessRoute', v)}
} placeholder={"53\n443\n1000-2000"}
> />
<Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item> </Form.Item>
</Col>
</Row>
<Row {...rowLayout}>
<Col {...colLayout}>
<Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.user"/>}>
<CommaSeparatedTextArea
value={form.user}
onChange={(v) => update('user', v)}
placeholder="email address"
/>
</Form.Item>
</Col>
<Col {...colLayout}>
<Form.Item label={t('pages.inbounds.network')}> <Form.Item label={t('pages.inbounds.network')}>
<Select <Select
value={form.network} value={form.network}
onChange={(v) => update('network', v)} onChange={(v) => update('network', v)}
options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} options={NETWORKS.map((n) => ({value: n, label: n || '(any)'}))}
/> />
</Form.Item> </Form.Item>
</Col>
<Col {...colLayout}>
<Form.Item label={t('pages.inbounds.protocol')}> <Form.Item label={t('pages.inbounds.protocol')}>
<Select <Select
mode="multiple" mode="multiple"
value={form.protocol} value={form.protocol}
onChange={(v) => update('protocol', v)} onChange={(v) => update('protocol', v)}
options={PROTOCOLS.map((p) => ({ value: p, label: p }))} options={PROTOCOLS.map((p) => ({value: p, label: p}))}
/> />
</Form.Item> </Form.Item>
</Col>
</Row>
<Form.Item label={t('pages.xray.ruleForm.attributes')}> <Row {...rowLayout}>
<Button size="small" icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} /> <Col {...colLayout}>
<Form.Item label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
value={form.inboundTag}
onChange={(v) => update('inboundTag', v)}
options={inboundTags.map((tag) => ({value: tag, label: tag}))}
/>
</Form.Item> </Form.Item>
<Form.Item wrapperCol={{ span: 24 }}> </Col>
<Col {...colLayout}>
<Form.Item label={t('pages.xray.ruleForm.outboundTag')}>
<Select
value={form.outboundTag}
onChange={(v) => update('outboundTag', v)}
options={outboundTags.map((tag) => ({value: tag, label: tag || '(none)'}))}
/>
</Form.Item>
</Col>
<Col {...colLayout}>
<Form.Item
label={
<LabelWithTooltip
labelKey="pages.xray.ruleForm.balancerTag"
tooltipKey="pages.xray.ruleForm.balancerTagTooltip"
/>
}
>
<Select
value={form.balancerTag}
onChange={(v) => update('balancerTag', v)}
options={balancerTags.map((tag) => ({value: tag, label: tag || '(none)'}))}
/>
</Form.Item>
</Col>
</Row>
<Row {...rowLayout}>
<Col {...colLayout}>
<Form.Item label={<LabelWithOnePerLineTooltip labelKey="IP"/>}>
<CommaSeparatedTextArea
value={form.ip}
onChange={(v) => update('ip', v)}
placeholder={`0.0.0.0/8\nfc00::/7\ngeoip:ir`}
/>
</Form.Item>
</Col>
<Col {...colLayout}>
<Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.inbounds.port"/>}>
<CommaSeparatedTextArea
value={form.port}
onChange={(v) => update('port', v)}
placeholder={`53\n443\n1000-2000`}
/>
</Form.Item>
</Col>
<Col {...colLayout}>
<Form.Item label={<LabelWithOnePerLineTooltip labelKey="domainName"/>}>
<CommaSeparatedTextArea
value={form.domain}
onChange={(v) => update('domain', v)}
placeholder={`google.com\ngeosite:cn`}
/>
</Form.Item>
</Col>
</Row>
<Form.Item>
<Space orientation="horizontal">
<Typography.Text>
{t('pages.xray.ruleForm.attributes')}
</Typography.Text>
<Button
size="small"
icon={<PlusOutlined/>}
onClick={() => update('attrs', [...form.attrs, ['', '']])}
/>
</Space>
</Form.Item>
{form.attrs.length > 0 && (
<Form.Item>
{form.attrs.map((attr, idx) => ( {form.attrs.map((attr, idx) => (
<Space.Compact key={idx} block className="mb-8"> <Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon> <InputAddon>{`${idx + 1}`}</InputAddon>
@ -217,83 +356,13 @@ export default function RuleFormModal({
}} }}
/> />
<Button <Button
icon={<MinusOutlined />} icon={<MinusOutlined/>}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))} onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/> />
</Space.Compact> </Space.Compact>
))} ))}
</Form.Item> </Form.Item>
)}
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.ip} onChange={(e) => update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.domain} onChange={(e) => update('domain', e.target.value)} placeholder="google.com, geosite:cn" />
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.user} onChange={(e) => update('user', e.target.value)} placeholder="email address" />
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
value={form.inboundTag}
onChange={(v) => update('inboundTag', v)}
options={inboundTags.map((tag) => ({ value: tag, label: tag }))}
/>
</Form.Item>
<Form.Item label={t('pages.xray.ruleForm.outboundTag')}>
<Select
value={form.outboundTag}
onChange={(v) => update('outboundTag', v)}
options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined />
</Tooltip>
}
>
<Select
value={form.balancerTag}
onChange={(v) => update('balancerTag', v)}
options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
); );

View file

@ -1171,7 +1171,8 @@
"info": "معلومات", "info": "معلومات",
"add": "أضف قاعدة", "add": "أضف قاعدة",
"edit": "عدل القاعدة", "edit": "عدل القاعدة",
"useComma": "عناصر مفصولة بفواصل" "useComma": "عناصر مفصولة بفواصل",
"onePerLine": "عنصر واحد لكل سطر"
}, },
"routing": { "routing": {
"dragToReorder": "اسحب لإعادة الترتيب" "dragToReorder": "اسحب لإعادة الترتيب"

View file

@ -1171,7 +1171,8 @@
"info": "Info", "info": "Info",
"add": "Add Rule", "add": "Add Rule",
"edit": "Edit Rule", "edit": "Edit Rule",
"useComma": "Comma-separated list" "useComma": "Comma-separated list",
"onePerLine": "One item per line"
}, },
"routing": { "routing": {
"dragToReorder": "Drag to reorder" "dragToReorder": "Drag to reorder"

View file

@ -1171,7 +1171,8 @@
"info": "Info", "info": "Info",
"add": "Agregar Regla", "add": "Agregar Regla",
"edit": "Editar Regla", "edit": "Editar Regla",
"useComma": "Elementos separados por comas" "useComma": "Elementos separados por comas",
"onePerLine": "Un elemento por línea"
}, },
"routing": { "routing": {
"dragToReorder": "Arrastra para reordenar" "dragToReorder": "Arrastra para reordenar"

View file

@ -1171,7 +1171,8 @@
"info": "اطلاعات", "info": "اطلاعات",
"add": "افزودن قانون", "add": "افزودن قانون",
"edit": "ویرایش قانون", "edit": "ویرایش قانون",
"useComma": "موارد جدا شده با کاما" "useComma": "موارد جدا شده با کاما",
"onePerLine": "یک مورد در هر خط"
}, },
"routing": { "routing": {
"dragToReorder": "برای تغییر ترتیب بکشید" "dragToReorder": "برای تغییر ترتیب بکشید"

View file

@ -1171,7 +1171,8 @@
"info": "Info", "info": "Info",
"add": "Tambahkan Aturan", "add": "Tambahkan Aturan",
"edit": "Edit Aturan", "edit": "Edit Aturan",
"useComma": "Item yang dipisahkan koma" "useComma": "Item yang dipisahkan koma",
"onePerLine": "Satu item per baris"
}, },
"routing": { "routing": {
"dragToReorder": "Seret untuk mengurutkan ulang" "dragToReorder": "Seret untuk mengurutkan ulang"

View file

@ -1171,7 +1171,8 @@
"info": "情報", "info": "情報",
"add": "ルール追加", "add": "ルール追加",
"edit": "ルール編集", "edit": "ルール編集",
"useComma": "カンマ区切りの項目" "useComma": "カンマ区切りの項目",
"onePerLine": "1行につき1項目"
}, },
"routing": { "routing": {
"dragToReorder": "ドラッグして並べ替え" "dragToReorder": "ドラッグして並べ替え"

View file

@ -1171,7 +1171,8 @@
"info": "Info", "info": "Info",
"add": "Adicionar Regra", "add": "Adicionar Regra",
"edit": "Editar Regra", "edit": "Editar Regra",
"useComma": "Itens separados por vírgula" "useComma": "Itens separados por vírgula",
"onePerLine": "Um item por linha"
}, },
"routing": { "routing": {
"dragToReorder": "Arraste para reordenar" "dragToReorder": "Arraste para reordenar"

View file

@ -1171,7 +1171,8 @@
"info": "Инфо", "info": "Инфо",
"add": "Создать правило", "add": "Создать правило",
"edit": "Редактировать правило", "edit": "Редактировать правило",
"useComma": "Элементы, разделённые запятыми" "useComma": "Элементы, разделённые запятыми",
"onePerLine": "Один элемент на строку"
}, },
"routing": { "routing": {
"dragToReorder": "Перетащите для изменения порядка" "dragToReorder": "Перетащите для изменения порядка"

View file

@ -1171,7 +1171,8 @@
"info": "Bilgi", "info": "Bilgi",
"add": "Kural Ekle", "add": "Kural Ekle",
"edit": "Kuralı Düzenle", "edit": "Kuralı Düzenle",
"useComma": "Virgülle ayrılmış öğeler" "useComma": "Virgülle ayrılmış öğeler",
"onePerLine": "Her satırda bir öğe"
}, },
"routing": { "routing": {
"dragToReorder": "Yeniden sıralamak için sürükleyin" "dragToReorder": "Yeniden sıralamak için sürükleyin"

View file

@ -1171,7 +1171,8 @@
"info": "Інфо", "info": "Інфо",
"add": "Додати правило", "add": "Додати правило",
"edit": "Редагувати правило", "edit": "Редагувати правило",
"useComma": "Елементи, розділені комами" "useComma": "Елементи, розділені комами",
"onePerLine": "Один елемент на рядок"
}, },
"routing": { "routing": {
"dragToReorder": "Перетягніть для зміни порядку" "dragToReorder": "Перетягніть для зміни порядку"

View file

@ -1171,7 +1171,8 @@
"info": "Thông tin", "info": "Thông tin",
"add": "Thêm quy tắc", "add": "Thêm quy tắc",
"edit": "Chỉnh sửa quy tắc", "edit": "Chỉnh sửa quy tắc",
"useComma": "Các mục được phân tách bằng dấu phẩy" "useComma": "Các mục được phân tách bằng dấu phẩy",
"onePerLine": "Một mục trên mỗi dòng"
}, },
"routing": { "routing": {
"dragToReorder": "Kéo để sắp xếp lại" "dragToReorder": "Kéo để sắp xếp lại"

View file

@ -1171,7 +1171,8 @@
"info": "信息", "info": "信息",
"add": "添加规则", "add": "添加规则",
"edit": "编辑规则", "edit": "编辑规则",
"useComma": "逗号分隔的项目" "useComma": "逗号分隔的项目",
"onePerLine": "每行一件"
}, },
"routing": { "routing": {
"dragToReorder": "拖动以重新排序" "dragToReorder": "拖动以重新排序"

View file

@ -1171,7 +1171,8 @@
"info": "資訊", "info": "資訊",
"add": "新增規則", "add": "新增規則",
"edit": "編輯規則", "edit": "編輯規則",
"useComma": "逗號分隔的項目" "useComma": "逗號分隔的項目",
"onePerLine": "每行一件"
}, },
"routing": { "routing": {
"dragToReorder": "拖曳以重新排序" "dragToReorder": "拖曳以重新排序"