diff --git a/frontend/src/lib/inbounds/label.ts b/frontend/src/lib/inbounds/label.ts new file mode 100644 index 000000000..59d80de7a --- /dev/null +++ b/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})`; +} diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx index 6b17e5a45..800e15747 100644 --- a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx +++ b/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]); diff --git a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx index e63298f6a..2c120d47d 100644 --- a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx +++ b/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]); diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 2d562768a..20dedc792 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/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], diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index ca1b77c1d..016cf03d1 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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], ); diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index a6cd026d9..278fcff6c 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/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 ( {label} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 8c17b73d1..e895474f2 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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 ( diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx index 25498bfd6..dfc34043e 100644 --- a/frontend/src/pages/clients/FilterDrawer.tsx +++ b/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], ); diff --git a/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx b/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx index dbcc7324c..a878f2de4 100644 --- a/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx +++ b/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} diff --git a/frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx b/frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx index 7a9fc9726..353766344 100644 --- a/frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx +++ b/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} diff --git a/frontend/src/pages/xray/routing/CriterionRow.tsx b/frontend/src/pages/xray/routing/CriterionRow.tsx index 1342d0ad8..78605a4f8 100644 --- a/frontend/src/pages/xray/routing/CriterionRow.tsx +++ b/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 ( diff --git a/frontend/src/pages/xray/routing/RouteTester.tsx b/frontend/src/pages/xray/routing/RouteTester.tsx index 6db29ad49..ae28da129 100644 --- a/frontend/src/pages/xray/routing/RouteTester.tsx +++ b/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(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 }))} /> diff --git a/frontend/src/pages/xray/routing/RuleCardList.tsx b/frontend/src/pages/xray/routing/RuleCardList.tsx index f7bfa6011..5bad6b0aa 100644 --- a/frontend/src/pages/xray/routing/RuleCardList.tsx +++ b/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 (
{rows.length === 0 ? ( @@ -74,7 +78,11 @@ export default function RuleCardList({
{t('pages.xray.Inbounds')} {rule.inboundTag ? ( - {chipPreview(rule.inboundTag)} + + + {inboundTagChipPreview(rule.inboundTag, remarkByTag)} + + ) : ( any )} diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx index fa6c46bdf..eafd1547e 100644 --- a/frontend/src/pages/xray/routing/RuleFormModal.tsx +++ b/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 = {}; - 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) }))} /> diff --git a/frontend/src/pages/xray/routing/helpers.ts b/frontend/src/pages/xray/routing/helpers.ts index 56a5fe721..433fe6dcd 100644 --- a/frontend/src/pages/xray/routing/helpers.ts +++ b/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 { + const map: Record = {}; + 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 { + 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[] { + return csv(tags).map((tag) => formatInboundTag(tag, remarkByTag)); +} + +export function inboundTagsDisplayTitle( + tags?: string, + remarkByTag: Record = {}, +): string | undefined { + const list = formatInboundTagList(tags, remarkByTag); + return list.length > 0 ? list.join(', ') : undefined; +} + +export function inboundTagChipPreview( + tags?: string, + remarkByTag: Record = {}, +): 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 }); diff --git a/frontend/src/pages/xray/routing/useRoutingColumns.tsx b/frontend/src/pages/xray/routing/useRoutingColumns.tsx index ea9ef9845..f8c33eb86 100644 --- a/frontend/src/pages/xray/routing/useRoutingColumns.tsx +++ b/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 { 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) => ( -
- {record.inboundTag && } - {record.user && } - {!record.inboundTag && !record.user && } -
- ), + render: (_v, record) => { + const inboundParts = formatInboundTagList(record.inboundTag, remarkByTag); + return ( +
+ {inboundParts.length > 0 && ( + + )} + {record.user && } + {inboundParts.length === 0 && !record.user && } +
+ ); + }, }, { 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], ); }