feat(clients/inbounds): IP log popups, clearer titles, tag-based inbound labels

Add an IP Log popup (view list + refresh + clear) to the client edit form and the Client Information modal, with IPs stacked vertically.

Identify inbounds by their xray tag (not remark/protocol:port) across every picker and chip: attach/detach modals, the attached-inbounds column and field, the filter drawer, and bulk-add. Add the tag field to the InboundOption schema (the backend already returned it).

Clarify modal titles/labels: Client Information (was More Information) and Inbound Information (was Inbound's Data); Client Information / QR Code titles now include the client email.

i18n: rename keys moreInformation->clientInfo and inboundData->inboundInfo with proper translations in all languages; addTitle->addClient, editTitle->editClient, addToGroupPlaceholder->groupName.
This commit is contained in:
MHSanaei
2026-05-29 23:22:49 +02:00
parent 12afb862ff
commit 987a6dd1e5
28 changed files with 231 additions and 116 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ export class AllSetting {
subPort = 2096;
subPath = '/sub/';
subJsonPath = '/json/';
subClashEnable = true;
subClashEnable = false;
subClashPath = '/clash/';
subDomain = '';
externalTrafficInformEnable = false;
@@ -63,7 +63,7 @@ export default function BulkAddToGroupModal({
>
<AutoComplete
value={value}
placeholder={t('pages.clients.addToGroupPlaceholder')}
placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))}
onChange={(v) => setValue(v ?? '')}
filterOption={(input, option) =>
@@ -36,7 +36,7 @@ export default function BulkAttachInboundsModal({
.filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
.map((ib) => ({
value: ib.id,
label: `${ib.remark ?? ''} (${ib.protocol ?? ''}@${ib.port ?? ''})`,
label: ib.tag,
}));
}, [inbounds]);
@@ -36,7 +36,7 @@ export default function BulkDetachInboundsModal({
.filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
.map((ib) => ({
value: ib.id,
label: `${ib.remark ?? ''} (${ib.protocol ?? ''}@${ib.port ?? ''})`,
label: ib.tag,
}));
}, [inbounds]);
@@ -100,7 +100,7 @@ export default function ClientBulkAddModal({
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
label: ib.tag ?? '',
value: ib.id,
})),
[inbounds],
+55 -19
View File
@@ -15,7 +15,7 @@ import {
Tag,
message,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
@@ -148,6 +148,7 @@ export default function ClientFormModal({
const [clientIps, setClientIps] = useState<string[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -155,6 +156,7 @@ export default function ClientFormModal({
useEffect(() => {
if (!open) return;
setIpsModalOpen(false);
if (isEdit && client) {
const et = Number(client.expiryTime) || 0;
@@ -259,9 +261,9 @@ export default function ClientFormModal({
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
label: ib.tag ?? '',
value: ib.id,
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
title: ib.tag ?? '',
})),
[inbounds],
);
@@ -279,6 +281,11 @@ export default function ClientFormModal({
}
}
function openIpsModal() {
setIpsModalOpen(true);
if (clientIps.length === 0) void loadIps();
}
async function clearIps() {
if (!isEdit || !client?.email) return;
setIpsClearing(true);
@@ -376,7 +383,7 @@ export default function ClientFormModal({
{messageContextHolder}
<Modal
open={open}
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
destroyOnHidden
okText={isEdit ? t('save') : t('create')}
cancelText={t('cancel')}
@@ -584,25 +591,54 @@ export default function ClientFormModal({
{isEdit && ipLimitEnable && (
<Form.Item label={t('pages.clients.ipLog')}>
<Space style={{ marginBottom: 8 }}>
<Button size="small" loading={ipsLoading} onClick={loadIps}>{t('refresh')}</Button>
<Button size="small" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
{t('pages.clients.clearAll')}
</Button>
</Space>
{clientIps.length > 0 ? (
<div>
{clientIps.map((ip, idx) => (
<Tag key={idx} color="blue" style={{ marginBottom: 4 }}>{ip}</Tag>
))}
</div>
) : (
<Tag>{t('tgbot.noIpRecord')}</Tag>
)}
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</Form.Item>
)}
</Form>
</Modal>
<Modal
open={ipsModalOpen}
title={`${t('pages.clients.ipLog')}${client?.email ? `${client.email}` : ''}`}
width={440}
onCancel={() => setIpsModalOpen(false)}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
{t('refresh')}
</Button>,
<Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
{t('pages.clients.clearAll')}
</Button>,
<Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
{t('close')}
</Button>,
]}
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((ip, idx) => (
<Tag
key={idx}
color="blue"
style={{
display: 'block',
width: 'fit-content',
maxWidth: '100%',
marginBottom: 6,
padding: '2px 8px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{ip}
</Tag>
))}
</div>
) : (
<Tag>{t('tgbot.noIpRecord')}</Tag>
)}
</Modal>
</>
);
}
+92 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, QrcodeOutlined } from '@ant-design/icons';
import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { useDatepicker } from '@/hooks/useDatepicker';
@@ -145,10 +145,16 @@ export default function ClientInfoModal({
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
const [messageApi, messageContextHolder] = message.useMessage();
const [links, setLinks] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<string[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
useEffect(() => {
if (!open) {
setLinks([]);
setClientIps([]);
setIpsModalOpen(false);
return;
}
if (!client?.subId) return;
@@ -197,12 +203,41 @@ export default function ClientInfoModal({
if (ok) messageApi.success(t('copied'));
}
async function loadIps() {
if (!client?.email) return;
setIpsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
} finally {
setIpsLoading(false);
}
}
async function clearIps() {
if (!client?.email) return;
setIpsClearing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
if (msg?.success) setClientIps([]);
} finally {
setIpsClearing(false);
}
}
function openIpsModal() {
setIpsModalOpen(true);
if (clientIps.length === 0) void loadIps();
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={client ? client.email : t('info')}
title={client ? `${t('pages.clients.clientInfo')}${client.email}` : t('pages.clients.clientInfo')}
footer={null}
width={640}
onCancel={() => onOpenChange(false)}
@@ -313,6 +348,14 @@ export default function ClientInfoModal({
<td>{t('pages.clients.ipLimit')}</td>
<td>{!client.limitIp ? <Tag></Tag> : <Tag>{client.limitIp}</Tag>}</td>
</tr>
<tr>
<td>{t('pages.inbounds.IPLimitlog')}</td>
<td>
<Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</td>
</tr>
<tr>
<td>{t('pages.inbounds.createdAt')}</td>
<td><Tag>{dateLabel(client.createdAt)}</Tag></td>
@@ -335,30 +378,27 @@ export default function ClientInfoModal({
if (ids.length === 0) return <span className="hint"></span>;
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
const inboundChip = (id: number, compact: boolean) => {
const inboundChip = (id: number) => {
const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const fullLabel = ib
? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})`
: `#${id}`;
const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
const label = ib?.tag ?? '';
return (
<Tooltip key={id} title={fullLabel}>
<Tag color={color}>{compact ? compactLabel : fullLabel}</Tag>
<Tooltip key={id} title={label}>
<Tag color={color}>{label}</Tag>
</Tooltip>
);
};
return (
<div className="chips">
{visible.map((id) => inboundChip(id, true))}
{visible.map((id) => inboundChip(id))}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div className="chips chips-stack">
{overflow.map((id) => inboundChip(id, false))}
{overflow.map((id) => inboundChip(id))}
</div>
}
>
@@ -510,6 +550,47 @@ export default function ClientInfoModal({
</>
)}
</Modal>
<Modal
open={ipsModalOpen}
title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? `${client.email}` : ''}`}
width={440}
onCancel={() => setIpsModalOpen(false)}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
{t('refresh')}
</Button>,
<Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
{t('pages.clients.clearAll')}
</Button>,
<Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
{t('close')}
</Button>,
]}
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((ip, idx) => (
<Tag
key={idx}
color="blue"
style={{
display: 'block',
width: 'fit-content',
maxWidth: '100%',
marginBottom: 6,
padding: '2px 8px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{ip}
</Tag>
))}
</div>
) : (
<Tag>{t('tgbot.noIpRecord')}</Tag>
)}
</Modal>
</>
);
}
+1 -1
View File
@@ -117,7 +117,7 @@ export default function ClientQrModal({
return (
<Modal
open={open}
title={client ? client.email : t('qrCode')}
title={client ? `${t('qrCode')}${client.email}` : t('qrCode')}
footer={null}
width={520}
centered
+4 -5
View File
@@ -299,8 +299,7 @@ export default function ClientsPage() {
function inboundLabel(id: number) {
const ib = inboundsById[id];
if (!ib) return `#${id}`;
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
return ib?.tag ?? '';
}
const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
@@ -589,7 +588,7 @@ export default function ClientsPage() {
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.moreInformation')}>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
@@ -678,7 +677,7 @@ export default function ClientsPage() {
const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
const compactLabel = ib?.tag ?? '';
return (
<Tooltip key={id} title={inboundLabel(id)}>
<Tag color={color} style={{ margin: 2 }}>
@@ -1118,7 +1117,7 @@ export default function ClientsPage() {
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('pages.clients.moreInformation')}>
<Tooltip title={t('pages.clients.clientInfo')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
</Tooltip>
<Switch
+1 -3
View File
@@ -50,9 +50,7 @@ export default function FilterDrawer({
const inboundOptions = useMemo(
() => inbounds.map((ib) => ({
value: ib.id,
label: ib.remark
? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
: `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
label: ib.tag ?? '',
})),
[inbounds],
);
@@ -69,7 +69,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} (${ib.protocol}@${ib.port})` }));
.map((ib) => ({ value: ib.id, label: ib.tag ?? '' }));
}, [dbInbounds, source]);
const filteredRows = useMemo(() => {
@@ -150,7 +150,7 @@ export default function AttachClientsModal({
}}
okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')}
title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark ?? '' })}
title={t('pages.inbounds.attachClientsTitle', { remark: source?.tag ?? '' })}
width={680}
>
{messageContextHolder}
@@ -139,7 +139,7 @@ export default function DetachClientsModal({
}}
okText={t('pages.inbounds.detachClients')}
cancelText={t('cancel')}
title={t('pages.inbounds.detachClientsTitle', { remark: source?.remark ?? '' })}
title={t('pages.inbounds.detachClientsTitle', { remark: source?.tag ?? '' })}
width={680}
>
{messageContextHolder}
@@ -480,7 +480,7 @@ export default function InboundInfoModal({
if (!dbInbound || !inbound) {
return (
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} />
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundInfo')} footer={null} width={640} />
);
}
@@ -1074,7 +1074,7 @@ export default function InboundInfoModal({
tabItems.push({ key: 'inbound', label: t('pages.xray.rules.inbound'), children: inboundTab });
return (
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} destroyOnHidden>
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundInfo')} footer={null} width={640} destroyOnHidden>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Modal>
);
+2 -2
View File
@@ -255,7 +255,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
});
}
} else {
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('info') });
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
}
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
@@ -626,7 +626,7 @@ export default function InboundList({
<span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('info')}>
<Tooltip title={t('pages.inbounds.inboundInfo')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
</Tooltip>
<Switch
+1
View File
@@ -39,6 +39,7 @@ export const ClientRecordSchema = z.object({
export const InboundOptionSchema = z.object({
id: z.number(),
remark: z.string().optional(),
tag: z.string().optional(),
protocol: z.string().optional(),
port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(),