Răsfoiți Sursa

chore(ui): redesign Edit Routing Rules modal

fgsfds 4 ore în urmă
părinte
comite
ba2baa9028

+ 26 - 0
frontend/src/components/ui/TooltipsHelper.tsx

@@ -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"
+  />
+}

+ 230 - 161
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -1,9 +1,10 @@
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
-import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
-import { InputAddon } from '@/components/ui';
-import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
+import {type ChangeEvent, useEffect, useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {Button, Col, Form, Input, Modal, Row, Select, Space, Typography} from 'antd';
+import {PlusOutlined, MinusOutlined} from '@ant-design/icons';
+import {InputAddon} from '@/components/ui';
+import {RuleFormSchema, type RuleFormValues} from '@/schemas/xray';
+import {LabelWithOnePerLineTooltip, LabelWithTooltip} from "@/components/ui/TooltipsHelper";
 
 export interface RoutingRule {
   type?: string;
@@ -20,6 +21,7 @@ export interface RoutingRule {
   attrs?: Record<string, string>;
   outboundTag?: string;
   balancerTag?: string;
+  
   [key: string]: unknown;
 }
 
@@ -59,6 +61,30 @@ function csv(value: string): string[] {
   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({
   open,
   rule,
@@ -68,10 +94,10 @@ export default function RuleFormModal({
   onClose,
   onConfirm,
 }: RuleFormModalProps) {
-  const { t } = useTranslation();
+  const {t} = useTranslation();
   const [form, setForm] = useState<FormState>(initialForm);
   const isEdit = rule != null;
-
+  
   useEffect(() => {
     if (!open) return;
     if (rule) {
@@ -94,10 +120,10 @@ export default function RuleFormModal({
       setForm(initialForm());
     }
   }, [open, rule]);
-
+  
   const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
-    setForm((prev) => ({ ...prev, [key]: value }));
-
+    setForm((prev) => ({...prev, [key]: value}));
+  
   function submit() {
     const validated = RuleFormSchema.safeParse(form);
     if (!validated.success) return;
@@ -128,173 +154,216 @@ export default function RuleFormModal({
     }
     onConfirm(out);
   }
-
+  
   const title = isEdit
     ? `${t('edit')} ${t('pages.xray.Routings')}`
     : `+ ${t('pages.xray.Routings')}`;
   const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
-
+  
+  const rowLayout = {gutter: 16};
+  const colLayout = {xs: 24, md: 8};
+  
   return (
     <Modal
       open={open}
       title={title}
       okText={okText}
       cancelText={t('close')}
-      mask={{ closable: false }}
-      width={640}
+      mask={{closable: false}}
+      width={1400}
       onOk={submit}
       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.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined />
-            </Tooltip>
-          }
-        >
-          <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
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined />
-            </Tooltip>
-          }
-        >
-          <Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
-        </Form.Item>
-
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined />
-            </Tooltip>
-          }
-        >
-          <Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
-        </Form.Item>
-
-        <Form.Item label={t('pages.inbounds.network')}>
-          <Select
-            value={form.network}
-            onChange={(v) => update('network', v)}
-            options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
-          />
-        </Form.Item>
-
-        <Form.Item label={t('pages.inbounds.protocol')}>
-          <Select
-            mode="multiple"
-            value={form.protocol}
-            onChange={(v) => update('protocol', v)}
-            options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
-          />
-        </Form.Item>
-
-        <Form.Item label={t('pages.xray.ruleForm.attributes')}>
-          <Button size="small" icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
-        </Form.Item>
-        <Form.Item wrapperCol={{ span: 24 }}>
-          {form.attrs.map((attr, idx) => (
-            <Space.Compact key={idx} block className="mb-8">
-              <InputAddon>{`${idx + 1}`}</InputAddon>
-              <Input
-                value={attr[0]}
-                placeholder={t('pages.nodes.name')}
-                onChange={(e) => {
-                  const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
-                  update('attrs', next);
-                }}
+      <Form layout="vertical" colon={false}>
+        <Row {...rowLayout}>
+          <Col {...colLayout}>
+            <Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.sourceIps"/>}>
+              <CommaSeparatedTextArea
+                value={form.sourceIP}
+                onChange={(v) => update('sourceIP', v)}
+                placeholder={"0.0.0.0/8\nfc00::/7\ngeoip:ir"}
               />
-              <Input
-                value={attr[1]}
-                placeholder={t('pages.xray.ruleForm.value')}
-                onChange={(e) => {
-                  const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
-                  update('attrs', next);
-                }}
+            </Form.Item>
+          </Col>
+          
+          <Col {...colLayout}>
+            <Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.sourcePort"/>}>
+              <CommaSeparatedTextArea
+                value={form.sourcePort}
+                onChange={(v) => update('sourcePort', v)}
+                placeholder={"53\n443\n1000-2000"}
               />
-              <Button
-                icon={<MinusOutlined />}
-                onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
+            </Form.Item>
+          </Col>
+          
+          <Col {...colLayout}>
+            <Form.Item label={<LabelWithOnePerLineTooltip labelKey="pages.xray.ruleForm.vlessRoute"/>}>
+              <CommaSeparatedTextArea
+                value={form.vlessRoute}
+                onChange={(v) => update('vlessRoute', v)}
+                placeholder={"53\n443\n1000-2000"}
               />
-            </Space.Compact>
-          ))}
-        </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>
+          </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')}>
+              <Select
+                value={form.network}
+                onChange={(v) => update('network', v)}
+                options={NETWORKS.map((n) => ({value: n, label: n || '(any)'}))}
+              />
+            </Form.Item>
+          </Col>
+          
+          <Col {...colLayout}>
+            <Form.Item label={t('pages.inbounds.protocol')}>
+              <Select
+                mode="multiple"
+                value={form.protocol}
+                onChange={(v) => update('protocol', v)}
+                options={PROTOCOLS.map((p) => ({value: p, label: p}))}
+              />
+            </Form.Item>
+          </Col>
+        </Row>
+        
+        <Row {...rowLayout}>
+          <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>
+          </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) => (
+              <Space.Compact key={idx} block className="mb-8">
+                <InputAddon>{`${idx + 1}`}</InputAddon>
+                <Input
+                  value={attr[0]}
+                  placeholder={t('pages.nodes.name')}
+                  onChange={(e) => {
+                    const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
+                    update('attrs', next);
+                  }}
+                />
+                <Input
+                  value={attr[1]}
+                  placeholder={t('pages.xray.ruleForm.value')}
+                  onChange={(e) => {
+                    const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
+                    update('attrs', next);
+                  }}
+                />
+                <Button
+                  icon={<MinusOutlined/>}
+                  onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
+                />
+              </Space.Compact>
+            ))}
+          </Form.Item>
+        )}
       </Form>
     </Modal>
   );
-}
+}

+ 2 - 1
web/translation/ar-EG.json

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

+ 2 - 1
web/translation/en-US.json

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

+ 2 - 1
web/translation/es-ES.json

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

+ 2 - 1
web/translation/fa-IR.json

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

+ 2 - 1
web/translation/id-ID.json

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

+ 2 - 1
web/translation/ja-JP.json

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

+ 2 - 1
web/translation/pt-BR.json

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

+ 2 - 1
web/translation/ru-RU.json

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

+ 2 - 1
web/translation/tr-TR.json

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

+ 2 - 1
web/translation/uk-UA.json

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

+ 2 - 1
web/translation/vi-VN.json

@@ -1171,7 +1171,8 @@
         "info": "Thông tin",
         "add": "Thêm 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": {
         "dragToReorder": "Kéo để sắp xếp lại"

+ 2 - 1
web/translation/zh-CN.json

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

+ 2 - 1
web/translation/zh-TW.json

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