feat(clients,routing): label inbounds by remark with tag fallback

Inbound pickers and chips across the Users area, the inbounds attach-clients modals, and the routing rule inbound-tags selector showed the auto-generated tag (in-443-tcp). Show the inbound remark when set, falling back to the tag.

Only display labels change; option values keep using the inbound id (or tag for routing rules, which match inbounds by tag), so filtering, attaching, and saved rules are unaffected. Routing reads remarks via a shared useInboundOptions hook that reuses the existing options query cache.
This commit is contained in:
MHSanaei 2026-06-02 14:14:25 +02:00
parent 10c185a592
commit 61105c2b1a
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
11 changed files with 45 additions and 14 deletions

View file

@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import { InboundOptionsSchema, type InboundOption } from '@/schemas/client';
async function fetchInboundOptions(): Promise<InboundOption[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useInboundOptions() {
return useQuery({
queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions,
staleTime: Infinity,
});
}

View file

@ -36,7 +36,7 @@ export default function BulkAttachInboundsModal({
.filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase())) .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
.map((ib) => ({ .map((ib) => ({
value: ib.id, value: ib.id,
label: ib.tag, label: ib.remark?.trim() || ib.tag || '',
})); }));
}, [inbounds]); }, [inbounds]);

View file

@ -36,7 +36,7 @@ export default function BulkDetachInboundsModal({
.filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase())) .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
.map((ib) => ({ .map((ib) => ({
value: ib.id, value: ib.id,
label: ib.tag, label: ib.remark?.trim() || ib.tag || '',
})); }));
}, [inbounds]); }, [inbounds]);

View file

@ -100,7 +100,7 @@ export default function ClientBulkAddModal({
() => (inbounds || []) () => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({ .map((ib) => ({
label: ib.tag ?? '', label: ib.remark?.trim() || ib.tag || '',
value: ib.id, value: ib.id,
})), })),
[inbounds], [inbounds],

View file

@ -261,9 +261,9 @@ export default function ClientFormModal({
() => (inbounds || []) () => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({ .map((ib) => ({
label: ib.tag ?? '', label: ib.remark?.trim() || ib.tag || '',
value: ib.id, value: ib.id,
title: ib.tag ?? '', title: ib.remark?.trim() || ib.tag || '',
})), })),
[inbounds], [inbounds],
); );

View file

@ -382,7 +382,7 @@ export default function ClientInfoModal({
const ib = inboundsById[id]; const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase(); const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default'; const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const label = ib?.tag ?? ''; const label = ib?.remark?.trim() || ib?.tag || '';
return ( return (
<Tooltip key={id} title={label}> <Tooltip key={id} title={label}>
<Tag color={color}>{label}</Tag> <Tag color={color}>{label}</Tag>

View file

@ -304,7 +304,7 @@ export default function ClientsPage() {
function inboundLabel(id: number) { function inboundLabel(id: number) {
const ib = inboundsById[id]; const ib = inboundsById[id];
return ib?.tag ?? ''; return ib?.remark?.trim() || ib?.tag || '';
} }
const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => { const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
@ -694,7 +694,7 @@ export default function ClientsPage() {
const ib = inboundsById[id]; const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase(); const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default'; const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const compactLabel = ib?.tag ?? ''; const compactLabel = ib?.remark?.trim() || ib?.tag || '';
return ( return (
<Tooltip key={id} title={inboundLabel(id)}> <Tooltip key={id} title={inboundLabel(id)}>
<Tag color={color} style={{ margin: 2 }}> <Tag color={color} style={{ margin: 2 }}>

View file

@ -50,7 +50,7 @@ export default function FilterDrawer({
const inboundOptions = useMemo( const inboundOptions = useMemo(
() => inbounds.map((ib) => ({ () => inbounds.map((ib) => ({
value: ib.id, value: ib.id,
label: ib.tag ?? '', label: ib.remark?.trim() || ib.tag || '',
})), })),
[inbounds], [inbounds],
); );

View file

@ -69,7 +69,7 @@ export default function AttachClientsModal({
if (!source) return []; if (!source) return [];
return (dbInbounds || []) return (dbInbounds || [])
.filter((ib) => ib.id !== source.id && isInboundMultiUser(ib)) .filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
.map((ib) => ({ value: ib.id, label: ib.tag ?? '' })); .map((ib) => ({ value: ib.id, label: ib.remark?.trim() || ib.tag || '' }));
}, [dbInbounds, source]); }, [dbInbounds, source]);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
@ -150,7 +150,7 @@ export default function AttachClientsModal({
}} }}
okText={t('pages.inbounds.attachClients')} okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')} cancelText={t('cancel')}
title={t('pages.inbounds.attachClientsTitle', { remark: source?.tag ?? '' })} title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark?.trim() || source?.tag || '' })}
width={680} width={680}
> >
{messageContextHolder} {messageContextHolder}

View file

@ -170,7 +170,7 @@ export default function AttachExistingClientsModal({
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }} okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
okText={t('pages.inbounds.attachClients')} okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')} cancelText={t('cancel')}
title={t('pages.inbounds.attachExistingTitle', { remark: target?.tag ?? '' })} title={t('pages.inbounds.attachExistingTitle', { remark: target?.remark?.trim() || target?.tag || '' })}
width={680} width={680}
> >
{messageContextHolder} {messageContextHolder}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, 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, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
export interface RoutingRule { export interface RoutingRule {
@ -72,6 +73,15 @@ export default function RuleFormModal({
const [form, setForm] = useState<FormState>(initialForm); const [form, setForm] = useState<FormState>(initialForm);
const isEdit = rule != null; 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]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (rule) { if (rule) {
@ -269,7 +279,7 @@ export default function RuleFormModal({
mode="multiple" mode="multiple"
value={form.inboundTag} value={form.inboundTag}
onChange={(v) => update('inboundTag', v)} onChange={(v) => update('inboundTag', v)}
options={inboundTags.map((tag) => ({ value: tag, label: tag }))} options={inboundTags.map((tag) => ({ value: tag, label: remarkByTag[tag] || tag }))}
/> />
</Form.Item> </Form.Item>