瀏覽代碼

feat(routing): show tag (remark) in routing rules list (#5151)

* feat(routing): show tag (remark) in routing rules list

Rules table and mobile cards showed raw inboundTag while the form already
used remarks. Display "tag (remark)" when a remark exists; saved rules
still store tags only.

Signed-off-by: aleskxyz <[email protected]>

* feat(inbounds): show "tag (remark)" consistently wherever an inbound is listed

Add a shared formatInboundLabel/formatInboundTag helper and apply the "tag (remark)" format across the routing rules table, mobile cards, the rule form and route tester, plus the client attach/detach/filter modals and the attached-inbounds column. Falls back to the bare tag when no distinct remark exists.

Also fix the routing rules list mis-rendering inbounds whose remark contains a comma: formatted entries are now carried as an array end to end instead of being joined and re-split on commas.

---------

Signed-off-by: aleskxyz <[email protected]>
Co-authored-by: Sanaei <[email protected]>
aleskxyz 1 天之前
父節點
當前提交
8f408d2d6a

+ 12 - 0
frontend/src/lib/inbounds/label.ts

@@ -0,0 +1,12 @@
+/**
+ * Display label for an inbound: `tag (remark)` when a distinct remark exists,
+ * otherwise just the tag. Falls back to the remark when no tag is set, and to an
+ * empty string when neither is present.
+ */
+export function formatInboundLabel(tag?: string, remark?: string): string {
+  const tagText = (tag || '').trim();
+  const remarkText = (remark || '').trim();
+  if (!tagText) return remarkText;
+  if (!remarkText || remarkText === tagText) return tagText;
+  return `${tagText} (${remarkText})`;
+}

+ 2 - 1
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
 import type { InboundOption } from '@/hooks/useClients';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkAttachResult } from '@/schemas/client';
 
 const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
@@ -36,7 +37,7 @@ export default function BulkAttachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
       }));
   }, [inbounds]);
 

+ 2 - 1
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
 import type { InboundOption } from '@/hooks/useClients';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkDetachResult } from '@/schemas/client';
 
 const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
@@ -36,7 +37,7 @@ export default function BulkDetachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
       }));
   }, [inbounds]);
 

+ 2 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -6,6 +6,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import { RandomUtil, SizeFormatter } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import { DateTimePicker } from '@/components/form';
 import { useClients, type InboundOption } from '@/hooks/useClients';
@@ -109,7 +110,7 @@ export default function ClientBulkAddModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
         value: ib.id,
       })),
     [inbounds],

+ 3 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -20,6 +20,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import { HttpUtil, RandomUtil } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { DateTimePicker } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
@@ -288,9 +289,9 @@ export default function ClientFormModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
         value: ib.id,
-        title: ib.remark?.trim() || ib.tag || '',
+        title: formatInboundLabel(ib.tag, ib.remark),
       })),
     [inbounds],
   );

+ 2 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -4,6 +4,7 @@ import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
 import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
@@ -316,7 +317,7 @@ export default function ClientInfoModal({
                         const ib = inboundsById[id];
                         const proto = (ib?.protocol || '').toLowerCase();
                         const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-                        const label = ib?.remark?.trim() || ib?.tag || '';
+                        const label = formatInboundLabel(ib?.tag, ib?.remark);
                         return (
                           <Tooltip key={id} title={label}>
                             <Tag color={color}>{label}</Tag>

+ 3 - 2
frontend/src/pages/clients/ClientsPage.tsx

@@ -47,6 +47,7 @@ import {
 } from '@ant-design/icons';
 
 import { useTheme } from '@/hooks/useTheme';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
@@ -303,7 +304,7 @@ export default function ClientsPage() {
 
   function inboundLabel(id: number) {
     const ib = inboundsById[id];
-    return ib?.remark?.trim() || ib?.tag || '';
+    return formatInboundLabel(ib?.tag, ib?.remark);
   }
 
   const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
@@ -684,7 +685,7 @@ export default function ClientsPage() {
           const ib = inboundsById[id];
           const proto = (ib?.protocol || '').toLowerCase();
           const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-          const compactLabel = ib?.remark?.trim() || ib?.tag || '';
+          const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
           return (
             <Tooltip key={id} title={inboundLabel(id)}>
               <Tag color={color} style={{ margin: 2 }}>

+ 2 - 1
frontend/src/pages/clients/FilterDrawer.tsx

@@ -18,6 +18,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import type { InboundOption } from '@/hooks/useClients';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { emptyFilters, type ClientFilters } from './filters';
 
 interface FilterDrawerProps {
@@ -50,7 +51,7 @@ export default function FilterDrawer({
   const inboundOptions = useMemo(
     () => inbounds.map((ib) => ({
       value: ib.id,
-      label: ib.remark?.trim() || ib.tag || '',
+      label: formatInboundLabel(ib.tag, ib.remark),
     })),
     [inbounds],
   );

+ 3 - 2
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -4,6 +4,7 @@ import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } f
 import type { ColumnsType } from 'antd/es/table';
 
 import { HttpUtil } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { isInboundMultiUser } from '../list';
 
@@ -69,7 +70,7 @@ export default function AttachClientsModal({
     if (!source) return [];
     return (dbInbounds || [])
       .filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
-      .map((ib) => ({ value: ib.id, label: ib.remark?.trim() || ib.tag || '' }));
+      .map((ib) => ({ value: ib.id, label: formatInboundLabel(ib.tag, ib.remark) }));
   }, [dbInbounds, source]);
 
   const filteredRows = useMemo(() => {
@@ -150,7 +151,7 @@ export default function AttachClientsModal({
       }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark?.trim() || source?.tag || '' })}
+      title={t('pages.inbounds.attachClientsTitle', { remark: formatInboundLabel(source?.tag, source?.remark) })}
       width={680}
     >
       {messageContextHolder}

+ 2 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -4,6 +4,7 @@ import { Alert, Input, Modal, Select, Space, Spin, Table, Tag, Typography, messa
 import type { ColumnsType } from 'antd/es/table';
 
 import { HttpUtil } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { DBInbound } from '@/models/dbinbound';
 
 interface AttachExistingClientsModalProps {
@@ -170,7 +171,7 @@ export default function AttachExistingClientsModal({
       okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachExistingTitle', { remark: target?.remark?.trim() || target?.tag || '' })}
+      title={t('pages.inbounds.attachExistingTitle', { remark: formatInboundLabel(target?.tag, target?.remark) })}
       width={680}
     >
       {messageContextHolder}

+ 2 - 2
frontend/src/pages/xray/routing/CriterionRow.tsx

@@ -2,8 +2,8 @@ import { Tooltip } from 'antd';
 
 import { csv } from './helpers';
 
-export default function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) {
-  const parts = csv(value);
+export default function CriterionRow({ label, value, values, title }: { label: string; value?: string; values?: string[]; title: string }) {
+  const parts = values ?? csv(value);
   if (parts.length === 0) return null;
   return (
     <Tooltip title={title}>

+ 6 - 2
frontend/src/pages/xray/routing/RouteTester.tsx

@@ -1,9 +1,11 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
 import { AimOutlined } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { buildRemarkByTag, formatInboundTag } from './helpers';
 
 interface RouteTesterProps {
   inboundTags: string[];
@@ -21,6 +23,8 @@ const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ lab
 
 export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
   const { t } = useTranslation();
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
   const [dest, setDest] = useState('');
   const [port, setPort] = useState<number | null>(443);
   const [network, setNetwork] = useState('tcp');
@@ -97,7 +101,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
             allowClear
             value={inboundTag}
             onChange={setInboundTag}
-            options={inboundTags.filter(Boolean).map((tag) => ({ label: tag, value: tag }))}
+            options={inboundTags.filter(Boolean).map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
           />
         </Col>
         <Col xs={12} sm={4}>

+ 10 - 2
frontend/src/pages/xray/routing/RuleCardList.tsx

@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
@@ -11,7 +12,8 @@ import {
   HolderOutlined,
 } from '@ant-design/icons';
 
-import { chipPreview, ruleCriteriaChips } from './helpers';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
 import type { RuleRow } from './types';
 
 interface RuleCardListProps {
@@ -36,6 +38,8 @@ export default function RuleCardList({
   confirmDelete,
 }: RuleCardListProps) {
   const { t } = useTranslation();
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
   return (
     <div className="rule-list">
       {rows.length === 0 ? (
@@ -74,7 +78,11 @@ export default function RuleCardList({
               <div className="flow-side">
                 <span className="flow-label">{t('pages.xray.Inbounds')}</span>
                 {rule.inboundTag ? (
-                  <Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
+                  <Tooltip title={inboundTagsDisplayTitle(rule.inboundTag, remarkByTag)}>
+                    <Tag color="blue" className="flow-tag">
+                      {inboundTagChipPreview(rule.inboundTag, remarkByTag)}
+                    </Tag>
+                  </Tooltip>
                 ) : (
                   <span className="criterion-empty">any</span>
                 )}

+ 3 - 8
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -5,6 +5,7 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
 import { InputAddon } from '@/components/ui';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
+import { buildRemarkByTag, formatInboundTag } from './helpers';
 
 export interface RoutingRule {
   type?: string;
@@ -74,13 +75,7 @@ export default function RuleFormModal({
   const isEdit = rule != null;
 
   const { data: inboundOptions } = useInboundOptions();
-  const remarkByTag = useMemo(() => {
-    const map: Record<string, string> = {};
-    for (const ib of inboundOptions || []) {
-      if (ib.tag) map[ib.tag] = ib.remark?.trim() || ib.tag;
-    }
-    return map;
-  }, [inboundOptions]);
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
 
   useEffect(() => {
     if (!open) return;
@@ -279,7 +274,7 @@ export default function RuleFormModal({
             mode="multiple"
             value={form.inboundTag}
             onChange={(v) => update('inboundTag', v)}
-            options={inboundTags.map((tag) => ({ value: tag, label: remarkByTag[tag] || tag }))}
+            options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
           />
         </Form.Item>
 

+ 53 - 2
frontend/src/pages/xray/routing/helpers.ts

@@ -11,13 +11,64 @@ export function csv(value?: string): string[] {
   return String(value).split(',').map((s) => s.trim()).filter(Boolean);
 }
 
-export function chipPreview(value?: string): string {
-  const parts = csv(value);
+export function chipPreviewParts(parts: string[]): string {
   if (parts.length === 0) return '';
   if (parts.length === 1) return parts[0];
   return `${parts[0]} +${parts.length - 1}`;
 }
 
+export function chipPreview(value?: string): string {
+  return chipPreviewParts(csv(value));
+}
+
+/** Same lookup as RuleFormModal inbound select: remark first, else tag. */
+export function buildRemarkByTag(
+  options: Array<{ tag?: string; remark?: string }>,
+): Record<string, string> {
+  const map: Record<string, string> = {};
+  for (const ib of options) {
+    if (ib.tag) map[ib.tag] = ib.remark?.trim() || ib.tag;
+  }
+  return map;
+}
+
+/** Format a single inbound tag as `tag (remark)`, or just `tag` when no distinct remark. */
+export function formatInboundTag(
+  tag: string,
+  remarkByTag: Record<string, string> = {},
+): string {
+  const label = remarkByTag[tag]?.trim();
+  if (!label || label === tag) return tag;
+  return `${tag} (${label})`;
+}
+
+/**
+ * Formatted inbound entries — `tag (remark)` when a distinct remark exists, else
+ * `tag`. Returns an array (not a joined string) so callers never have to re-split
+ * on commas, which a remark may legitimately contain.
+ */
+export function formatInboundTagList(
+  tags?: string,
+  remarkByTag: Record<string, string> = {},
+): string[] {
+  return csv(tags).map((tag) => formatInboundTag(tag, remarkByTag));
+}
+
+export function inboundTagsDisplayTitle(
+  tags?: string,
+  remarkByTag: Record<string, string> = {},
+): string | undefined {
+  const list = formatInboundTagList(tags, remarkByTag);
+  return list.length > 0 ? list.join(', ') : undefined;
+}
+
+export function inboundTagChipPreview(
+  tags?: string,
+  remarkByTag: Record<string, string> = {},
+): string {
+  return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
+}
+
 export function ruleCriteriaChips(rule: RuleRow) {
   const chips: { label: string; value?: string }[] = [];
   if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });

+ 21 - 9
frontend/src/pages/xray/routing/useRoutingColumns.tsx

@@ -13,7 +13,9 @@ import {
 } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import CriterionRow from './CriterionRow';
+import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
 import type { RuleRow } from './types';
 
 interface RoutingColumnsParams {
@@ -40,6 +42,8 @@ export function useRoutingColumns({
   confirmDelete,
 }: RoutingColumnsParams): ColumnsType<RuleRow> {
   const { t } = useTranslation();
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
   return useMemo(
     () => [
       {
@@ -131,13 +135,22 @@ export function useRoutingColumns({
         align: 'left',
         width: 180,
         key: 'inbound',
-        render: (_v, record) => (
-          <div className="criterion-flow">
-            {record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
-            {record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
-            {!record.inboundTag && !record.user && <span className="criterion-empty">—</span>}
-          </div>
-        ),
+        render: (_v, record) => {
+          const inboundParts = formatInboundTagList(record.inboundTag, remarkByTag);
+          return (
+            <div className="criterion-flow">
+              {inboundParts.length > 0 && (
+                <CriterionRow
+                  label="Tag"
+                  values={inboundParts}
+                  title={`Inbound tag: ${inboundTagsDisplayTitle(record.inboundTag, remarkByTag) ?? inboundParts.join(', ')}`}
+                />
+              )}
+              {record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
+              {inboundParts.length === 0 && !record.user && <span className="criterion-empty">—</span>}
+            </div>
+          );
+        },
       },
       {
         title: t('pages.xray.Outbounds'),
@@ -171,7 +184,6 @@ export function useRoutingColumns({
           ),
       },
     ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, isMobile, rowsLength, showSource, showBalancer],
+    [t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
   );
 }