mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 14:07:13 +00:00
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 <39186039+aleskxyz@users.noreply.github.com> * 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 <39186039+aleskxyz@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user