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
+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>
</>
);
}