mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat(clients): compact link + inbound rows in the info modal and table
ClientInfoModal — Copy URL section reskinned:
- Each link is a single row: [PROTOCOL] [remark] [copy] [QR]
instead of a card with the raw 200-char URL printed inline
- Remark is parsed per-protocol — VMess pulls it from the
base64-JSON `ps` field, the rest from the `#fragment`
- The row title strips the client email suffix so the same
string isn't repeated three times in the modal; the QR
popover still uses the full remark (it's the QR's own name
for the download file)
- QR button opens an inline Popover with the existing QrPanel,
size 220, destroyed on close
- Subscription section uses the same row layout (SUB / JSON
tags, clickable subId, copy + QR actions)
- New per-protocol Tag colors so the protocol is identifiable
at a glance
ClientInfoModal — Attached inbounds + ClientsPage table column:
- Chip format changed from `${remark} (${proto}:${port})` to
just `${proto}:${port}` — when an admin attaches 5 inbounds
to one client the remark was repeated 5 times and wrapped onto
two lines
- Only the first inbound chip is shown; the rest collapse into
a `+N` chip that opens a Popover with the full list (remark
included). INBOUND_CHIP_LIMIT = 1
- Per-protocol Tag colors
- Tooltip on each chip shows the full `${remark}
(${proto}:${port})`
- Table column pinned to width: 170 so the row doesn't reserve
the old 300px of whitespace next to the compact chip
Comment row in the info table is always shown now (renders `-`
when unset) so the layout doesn't jump per-client.
VmessSecuritySchema gets a preprocess pass that maps legacy
`security: ""` (persisted on pre-enum-lock VMess inbounds) back
to `'auto'`. z.enum's `.default()` only fires on a missing
field, not on an empty string — without this, old rows fail
validation with "expected one of aes-128-gcm|chacha20-poly1305|
auto|none|zero". `z.infer` is taken from the raw enum so the
inferred type stays the union, not `unknown`.
i18n adds a `more` key (en-US + fa-IR) used by the overflow
chip label.
This commit is contained in:
parent
069c57adff
commit
66f5026356
6 changed files with 445 additions and 173 deletions
|
|
@ -37,6 +37,24 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chips-stack {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: 280px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chip-more {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip-more:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.link-panel {
|
||||
|
|
@ -84,3 +102,46 @@
|
|||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
text-decoration-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.link-row-tag {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.link-row-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-row-title-anchor {
|
||||
color: var(--ant-color-primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 35%, transparent);
|
||||
transition: text-decoration-color 120ms ease;
|
||||
}
|
||||
|
||||
.link-row-title-anchor:hover {
|
||||
text-decoration-color: var(--ant-color-primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,85 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import QrPanel from '@/pages/inbounds/QrPanel';
|
||||
import './ClientInfoModal.css';
|
||||
|
||||
const PROTOCOL_COLORS: Record<string, string> = {
|
||||
VLESS: 'blue',
|
||||
VMESS: 'geekblue',
|
||||
TROJAN: 'volcano',
|
||||
SS: 'magenta',
|
||||
HYSTERIA: 'cyan',
|
||||
HY2: 'green',
|
||||
};
|
||||
|
||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
vless: 'blue',
|
||||
vmess: 'geekblue',
|
||||
trojan: 'volcano',
|
||||
shadowsocks: 'magenta',
|
||||
hysteria: 'cyan',
|
||||
hysteria2: 'green',
|
||||
wireguard: 'gold',
|
||||
http: 'purple',
|
||||
mixed: 'lime',
|
||||
tunnel: 'orange',
|
||||
};
|
||||
|
||||
const INBOUND_CHIP_LIMIT = 1;
|
||||
|
||||
// 3x-ui's genRemark concatenates inbound remark + client email (and an
|
||||
// optional extra) using a configurable separator. The email half is
|
||||
// redundant in the row title — the modal already names the client by
|
||||
// email at the top — so trimEmail strips it back out for the row only.
|
||||
// The original remark is preserved for the QR (it's the QR's own name).
|
||||
function trimEmail(remark: string, email: string): string {
|
||||
if (!email) return remark;
|
||||
const e = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return remark
|
||||
.replace(new RegExp(`[-_.\\s|]+${e}$`), '')
|
||||
.replace(new RegExp(`^${e}[-_.\\s|]+`), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseLinkMeta(link: string): { protocol: string; remark: string } {
|
||||
const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
|
||||
const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
|
||||
const protocolMap: Record<string, string> = {
|
||||
vless: 'VLESS',
|
||||
vmess: 'VMESS',
|
||||
trojan: 'TROJAN',
|
||||
ss: 'SS',
|
||||
hysteria: 'HYSTERIA',
|
||||
hysteria2: 'HY2',
|
||||
hy2: 'HY2',
|
||||
};
|
||||
const protocol = protocolMap[scheme] ?? scheme.toUpperCase() ?? 'LINK';
|
||||
|
||||
let remark = '';
|
||||
if (scheme === 'vmess') {
|
||||
try {
|
||||
const body = link.slice('vmess://'.length).split('#')[0];
|
||||
const json = JSON.parse(atob(body)) as { ps?: unknown };
|
||||
if (typeof json?.ps === 'string') remark = json.ps;
|
||||
} catch { /* fall through to fragment parsing */ }
|
||||
}
|
||||
if (!remark) {
|
||||
const hashIdx = link.indexOf('#');
|
||||
if (hashIdx >= 0) {
|
||||
const raw = link.slice(hashIdx + 1);
|
||||
try { remark = decodeURIComponent(raw); }
|
||||
catch { remark = raw; }
|
||||
}
|
||||
}
|
||||
return { protocol, remark };
|
||||
}
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
|
|
@ -107,192 +179,273 @@ export default function ClientInfoModal({
|
|||
footer={null}
|
||||
width={640}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
{client && (
|
||||
<>
|
||||
<table className="info-table block">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('pages.clients.online')}</td>
|
||||
<td>
|
||||
{client.enable && isOnline
|
||||
? <Tag color="green">{t('pages.clients.online')}</Tag>
|
||||
: <Tag>{t('pages.clients.offline')}</Tag>}
|
||||
<span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('status')}</td>
|
||||
<td>
|
||||
<Tag color={client.enable ? 'green' : 'default'}>
|
||||
{client.enable ? t('enabled') : t('disabled')}
|
||||
</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.email')}</td>
|
||||
<td>
|
||||
{client.email
|
||||
? <Tag color="green">{client.email}</Tag>
|
||||
: <Tag color="red">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.subId')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
|
||||
{client.subId && (
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{client.uuid && (
|
||||
>
|
||||
{client && (
|
||||
<>
|
||||
<table className="info-table block">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('pages.clients.uuid')}</td>
|
||||
<td>{t('pages.clients.online')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.uuid}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
|
||||
{client.enable && isOnline
|
||||
? <Tag color="green">{t('pages.clients.online')}</Tag>
|
||||
: <Tag>{t('pages.clients.offline')}</Tag>}
|
||||
<span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{client.password && (
|
||||
<tr>
|
||||
<td>{t('password')}</td>
|
||||
<td>{t('status')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.password}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
|
||||
<Tag color={client.enable ? 'green' : 'default'}>
|
||||
{client.enable ? t('enabled') : t('disabled')}
|
||||
</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{client.auth && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.auth')}</td>
|
||||
<td>{t('pages.clients.email')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.auth}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
|
||||
{client.email
|
||||
? <Tag color="green">{client.email}</Tag>
|
||||
: <Tag color="red">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.clients.flow')}</td>
|
||||
<td>
|
||||
{client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.traffic')}</td>
|
||||
<td>
|
||||
<Tag>
|
||||
↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
|
||||
{' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
|
||||
</Tag>
|
||||
<span className="hint">
|
||||
{SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('remained')}</td>
|
||||
<td>
|
||||
{remaining < 0
|
||||
? <Tag color="purple">∞</Tag>
|
||||
: <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.expireDate')}</td>
|
||||
<td>
|
||||
{!client.expiryTime
|
||||
? <Tag color="purple">∞</Tag>
|
||||
: <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
|
||||
{(client.expiryTime ?? 0) > 0 && (
|
||||
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.ipLimit')}</td>
|
||||
<td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.createdAt')}</td>
|
||||
<td><Tag>{dateLabel(client.createdAt)}</Tag></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.updatedAt')}</td>
|
||||
<td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
|
||||
</tr>
|
||||
{client.comment && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.comment')}</td>
|
||||
<td><Tag className="info-large-tag">{client.comment}</Tag></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.clients.attachedInbounds')}</td>
|
||||
<td>
|
||||
<div className="chips">
|
||||
{(client.inboundIds || []).map((id) => {
|
||||
const ib = inboundsById[id];
|
||||
return (
|
||||
<Tag key={id} color="blue">
|
||||
{ib ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})` : `#${id}`}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{(!client.inboundIds || client.inboundIds.length === 0) && (
|
||||
<span className="hint">—</span>
|
||||
<td>{t('pages.clients.subId')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
|
||||
{client.subId && (
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{client.uuid && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.uuid')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.uuid}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{client.password && (
|
||||
<tr>
|
||||
<td>{t('password')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.password}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{client.auth && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.auth')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.auth}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.clients.flow')}</td>
|
||||
<td>
|
||||
{client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.traffic')}</td>
|
||||
<td>
|
||||
<Tag>
|
||||
↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
|
||||
{' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
|
||||
</Tag>
|
||||
<span className="hint">
|
||||
{SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('remained')}</td>
|
||||
<td>
|
||||
{remaining < 0
|
||||
? <Tag color="purple">∞</Tag>
|
||||
: <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.expireDate')}</td>
|
||||
<td>
|
||||
{!client.expiryTime
|
||||
? <Tag color="purple">∞</Tag>
|
||||
: <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
|
||||
{(client.expiryTime ?? 0) > 0 && (
|
||||
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.ipLimit')}</td>
|
||||
<td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.createdAt')}</td>
|
||||
<td><Tag>{dateLabel(client.createdAt)}</Tag></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.updatedAt')}</td>
|
||||
<td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
|
||||
</tr>
|
||||
{client.comment && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.comment')}</td>
|
||||
<td><Tag className="info-large-tag">{client.comment}</Tag></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.clients.attachedInbounds')}</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const ids = client.inboundIds || [];
|
||||
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 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}`;
|
||||
return (
|
||||
<Tooltip key={id} title={fullLabel}>
|
||||
<Tag color={color}>{compact ? compactLabel : fullLabel}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="chips">
|
||||
{visible.map((id) => inboundChip(id, true))}
|
||||
{overflow.length > 0 && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={
|
||||
<div className="chips chips-stack">
|
||||
{overflow.map((id) => inboundChip(id, false))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color="default" className="chip-more">
|
||||
+{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => (
|
||||
<div key={idx} className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{`${t('pages.clients.link')} ${idx + 1}`}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{link}</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => {
|
||||
const meta = parseLinkMeta(link);
|
||||
const qrRemark = meta.remark || `${t('pages.clients.link')} ${idx + 1}`;
|
||||
const rowTitle = trimEmail(meta.remark, client.email)
|
||||
|| `${t('pages.clients.link')} ${idx + 1}`;
|
||||
return (
|
||||
<div key={idx} className="link-row">
|
||||
<Tag color={PROTOCOL_COLORS[meta.protocol] ?? 'default'} className="link-row-tag">
|
||||
{meta.protocol}
|
||||
</Tag>
|
||||
<span className="link-row-title" title={qrRemark}>{rowTitle}</span>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={link} remark={qrRemark} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSubscription && subLink && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{t('subscription.title')}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
|
||||
</div>
|
||||
{subJsonLink && (
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">JSON</Tag>
|
||||
{showSubscription && subLink && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
<div className="link-row">
|
||||
<Tag color="green" className="link-row-tag">SUB</Tag>
|
||||
<a
|
||||
href={subLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-row-title link-row-title-anchor"
|
||||
title={subLink}
|
||||
>
|
||||
{client.subId}
|
||||
</a>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={subLink} remark={`${client.email} — ${t('subscription.title')}`} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{subJsonLink && (
|
||||
<div className="link-row">
|
||||
<Tag color="purple" className="link-row-tag">JSON</Tag>
|
||||
<a
|
||||
href={subJsonLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-row-title link-row-title-anchor"
|
||||
title={subJsonLink}
|
||||
>
|
||||
{client.subId}
|
||||
</a>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@ interface FilterState {
|
|||
inboundFilter?: number;
|
||||
}
|
||||
|
||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
vless: 'blue',
|
||||
vmess: 'geekblue',
|
||||
trojan: 'volcano',
|
||||
shadowsocks: 'magenta',
|
||||
hysteria: 'cyan',
|
||||
hysteria2: 'green',
|
||||
wireguard: 'gold',
|
||||
http: 'purple',
|
||||
mixed: 'lime',
|
||||
tunnel: 'orange',
|
||||
};
|
||||
const INBOUND_CHIP_LIMIT = 1;
|
||||
|
||||
function readFilterState(): FilterState {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||||
|
|
@ -531,12 +545,45 @@ export default function ClientsPage() {
|
|||
sortableCol({
|
||||
title: t('pages.clients.attachedInbounds'),
|
||||
key: 'inboundIds',
|
||||
width: 170,
|
||||
render: (_v, record) => {
|
||||
const ids = record.inboundIds || [];
|
||||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||||
return ids.map((id) => (
|
||||
<Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
|
||||
));
|
||||
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
||||
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
||||
const chip = (id: number, compact: boolean) => {
|
||||
const ib = inboundsById[id];
|
||||
const proto = (ib?.protocol || '').toLowerCase();
|
||||
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
||||
const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
|
||||
return (
|
||||
<Tooltip key={id} title={inboundLabel(id)}>
|
||||
<Tag color={color} style={{ margin: 2 }}>
|
||||
{compact ? compactLabel : inboundLabel(id)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{visible.map((id) => chip(id, true))}
|
||||
{overflow.length > 0 && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
||||
{overflow.map((id) => chip(id, false))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
||||
+{overflow.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
}, 'inboundIds'),
|
||||
sortableCol({
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const VmessSecuritySchema = z.enum([
|
||||
const VmessSecurityEnum = z.enum([
|
||||
'aes-128-gcm',
|
||||
'chacha20-poly1305',
|
||||
'auto',
|
||||
'none',
|
||||
'zero',
|
||||
]);
|
||||
export type VmessSecurity = z.infer<typeof VmessSecuritySchema>;
|
||||
|
||||
// Legacy rows persisted `security: ""` (especially on VMess inbounds
|
||||
// created before the enum was nailed down). Preprocess maps the empty
|
||||
// string back to the documented default so existing data parses cleanly
|
||||
// — subsequent writes serialize the normalized value.
|
||||
export const VmessSecuritySchema = z.preprocess(
|
||||
(val) => (val === '' ? 'auto' : val),
|
||||
VmessSecurityEnum,
|
||||
);
|
||||
export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;
|
||||
|
||||
export const VmessClientSchema = z.object({
|
||||
id: z.uuid(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"update": "Update",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"more": "more",
|
||||
"download": "Download",
|
||||
"remark": "Remark",
|
||||
"enable": "Enabled",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"update": "بهروزرسانی",
|
||||
"copy": "کپی",
|
||||
"copied": "کپی شد",
|
||||
"more": "بیشتر",
|
||||
"download": "دانلود",
|
||||
"remark": "نام",
|
||||
"enable": "فعال",
|
||||
|
|
|
|||
Loading…
Reference in a new issue