fix(frontend): info-modal cleanup + 2FA QR + outbound link import

- 2FA QR: matrix-snap canvas + opaque background to drop white margin
- Inbound info modal: stack Mixed/HTTP/Tunnel as info-rows, hide tab
  strip when only the Inbound tab applies
- Add inline VLESS Reverse tag input on first-client form
- Hide Protocol tab for TUN (no form yet)
- Outbound link converter: route through Outbound.fromLink so
  vless/trojan/ss/hysteria(2) imports work alongside vmess; fix stray
  implicit global in fromLink

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 23:38:35 +02:00
parent 84b155698b
commit 085a12e469
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 157 additions and 87 deletions

View file

@ -1338,7 +1338,7 @@ export class Outbound extends CommonClass {
}
static fromLink(link) {
data = link.split('://');
const data = link.split('://');
if (data.length != 2) return null;
switch (data[0].toLowerCase()) {
case Protocols.VMess:

View file

@ -579,7 +579,10 @@ watch(
</a-tab-pane>
<!-- ============================== PROTOCOL ============================== -->
<a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
<!-- TUN has no per-protocol form yet (interface/mtu/gateway live in
settings JSON), so the tab would render empty hide it until
a TUN form is added. -->
<a-tab-pane v-if="protocol !== Protocols.TUN" key="protocol" :tab="t('pages.inbounds.protocol')">
<!-- Multi-user inbounds: in add mode embed the first client form,
in edit mode show a count summary. -->
<template v-if="isMultiUser">
@ -643,6 +646,10 @@ watch(
</a-select>
</a-form-item>
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
<a-input v-model:value="firstClient.reverseTag" placeholder="Optional reverse tag" />
</a-form-item>
<a-form-item label="Subscription">
<a-input v-model:value="firstClient.subId">
<template #addonAfter>

View file

@ -366,72 +366,96 @@ const showSubscriptionTab = computed(
</table>
<!-- Tunnel -->
<table v-if="inbound.protocol === Protocols.TUNNEL" class="info-table protocol-table">
<thead>
<tr>
<th>{{ t('pages.inbounds.targetAddress') }}</th>
<th>{{ t('pages.inbounds.destinationPort') }}</th>
<th>{{ t('pages.inbounds.network') }}</th>
<th>FollowRedirect</th>
</tr>
</thead>
<tbody>
<tr>
<td><a-tag color="green">{{ inbound.settings.address }}</a-tag></td>
<td><a-tag color="green">{{ inbound.settings.port }}</a-tag></td>
<td><a-tag color="green">{{ inbound.settings.network }}</a-tag></td>
<td><a-tag color="green">{{ inbound.settings.followRedirect }}</a-tag></td>
</tr>
</tbody>
</table>
<dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
<div class="info-row">
<dt>{{ t('pages.inbounds.targetAddress') }}</dt>
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd>
</div>
<div class="info-row">
<dt>{{ t('pages.inbounds.destinationPort') }}</dt>
<dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd>
</div>
<div class="info-row">
<dt>{{ t('pages.inbounds.network') }}</dt>
<dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd>
</div>
<div class="info-row">
<dt>FollowRedirect</dt>
<dd>
<a-tag :color="inbound.settings.followRedirect ? 'green' : 'red'">
{{ inbound.settings.followRedirect ? t('enabled') : t('disabled') }}
</a-tag>
</dd>
</div>
</dl>
<!-- Mixed -->
<table v-if="dbInbound.isMixed" class="info-table protocol-table">
<thead>
<tr>
<th>Auth</th>
<th>UDP</th>
<th>IP</th>
</tr>
</thead>
<tbody>
<tr>
<td><a-tag color="green">{{ inbound.settings.auth }}</a-tag></td>
<td><a-tag color="green">{{ inbound.settings.udp }}</a-tag></td>
<td><a-tag color="green">{{ inbound.settings.ip }}</a-tag></td>
</tr>
<template v-if="inbound.settings.auth === 'password'">
<tr>
<td></td>
<td>{{ t('username') }}</td>
<td>{{ t('password') }}</td>
</tr>
<tr v-for="(account, idx) in inbound.settings.accounts" :key="idx">
<td>{{ idx }}</td>
<td><a-tag color="green">{{ account.user }}</a-tag></td>
<td><a-tag color="green">{{ account.pass }}</a-tag></td>
</tr>
</template>
</tbody>
</table>
<dl v-if="dbInbound.isMixed" class="info-list info-list-block">
<div class="info-row">
<dt>Auth</dt>
<dd>
<a-tag :color="inbound.settings.auth === 'password' ? 'green' : 'orange'">
{{ inbound.settings.auth }}
</a-tag>
</dd>
</div>
<div class="info-row">
<dt>UDP</dt>
<dd>
<a-tag :color="inbound.settings.udp ? 'green' : 'red'">
{{ inbound.settings.udp ? t('enabled') : t('disabled') }}
</a-tag>
</dd>
</div>
<div v-if="inbound.settings.ip" class="info-row">
<dt>IP</dt>
<dd><a-tag class="value-tag">{{ inbound.settings.ip }}</a-tag></dd>
</div>
<template v-if="inbound.settings.auth === 'password' && inbound.settings.accounts?.length">
<div
v-for="(account, idx) in inbound.settings.accounts"
:key="idx"
class="info-row"
>
<dt>{{ t('username') }} #{{ idx + 1 }}</dt>
<dd class="account-row">
<a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
<span class="account-sep">:</span>
<a-tag class="value-tag">{{ account.pass }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</dd>
</div>
</template>
</dl>
<!-- HTTP accounts -->
<table v-if="dbInbound.isHTTP" class="info-table protocol-table">
<thead>
<tr>
<th></th>
<th>{{ t('username') }}</th>
<th>{{ t('password') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(account, idx) in inbound.settings.accounts" :key="idx">
<td>{{ idx }}</td>
<td><a-tag color="green">{{ account.user }}</a-tag></td>
<td><a-tag color="green">{{ account.pass }}</a-tag></td>
</tr>
</tbody>
</table>
<dl v-if="dbInbound.isHTTP && inbound.settings.accounts?.length" class="info-list info-list-block">
<div
v-for="(account, idx) in inbound.settings.accounts"
:key="idx"
class="info-row"
>
<dt>{{ t('username') }} #{{ idx + 1 }}</dt>
<dd class="account-row">
<a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
<span class="account-sep">:</span>
<a-tag class="value-tag">{{ account.pass }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</dd>
</div>
</dl>
<!-- WireGuard server config + peers -->
<table v-if="dbInbound.isWireguard" class="info-table protocol-table wg-table">
@ -799,6 +823,25 @@ const showSubscriptionTab = computed(
border-bottom: none;
}
/* When info-list is rendered as a second block (e.g. protocol details
* after the top transport/security block), give it a small top spacing
* so the two groups read as separate. */
.info-list-block {
margin-top: 10px;
}
.account-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.account-sep {
opacity: 0.55;
font-weight: 600;
}
.info-row dt {
margin: 0;
font-size: 13px;

View file

@ -34,6 +34,25 @@ const qrCanvas = ref(null);
let totp = null;
// Byte-mode capacities (level L) for QR versions 1..40 used to pick
// the matrix width up front so the canvas size is an exact multiple of
// pixelSize. Without this, QRious renders at floor(size/matrix) and
// centers, leaving a white margin around the QR.
const QR_L_BYTE_CAPACITY = [
17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
];
function pickQrMatrixWidth(value) {
const byteLen = new TextEncoder().encode(value).length;
for (let i = 0; i < QR_L_BYTE_CAPACITY.length; i++) {
if (byteLen <= QR_L_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
}
return 17 + 4 * 40;
}
function buildTotp() {
totp = new OTPAuth.TOTP({
issuer: '3x-ui',
@ -48,16 +67,19 @@ function buildTotp() {
async function paintQr() {
await nextTick();
if (!qrCanvas.value || !totp) return;
// QRious draws into a <canvas>; we don't need a wrapping div.
const value = totp.toString();
const matrixWidth = pickQrMatrixWidth(value);
const pixelSize = Math.max(1, Math.floor(200 / matrixWidth));
const exactSize = matrixWidth * pixelSize;
// eslint-disable-next-line no-new
new QRious({
element: qrCanvas.value,
size: 200,
value: totp.toString(),
size: exactSize,
value,
background: 'white',
backgroundAlpha: 0,
backgroundAlpha: 1,
foreground: 'black',
padding: 2,
padding: 0,
level: 'L',
});
}
@ -145,6 +167,10 @@ async function copyToken() {
cursor: pointer;
width: 100% !important;
height: 100% !important;
/* Drawing buffer is matrix-snapped (smaller than display size); scale
* up crisply so the QR fills the box without blurring. */
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.qr-token {

View file

@ -154,29 +154,23 @@ function onOk() {
}
// ============== Link outbound ==============
// The legacy "convert link" button takes a vmess://, vless://, ss://,
// trojan:// or hysteria2:// share-link string and rebuilds the
// outbound from it. The Outbound class doesn't have a native parser
// we only support a friendly URL parse for the common shapes.
// Mirrors the legacy convertLink: dispatches into Outbound.fromLink,
// which handles vmess:// (base64 JSON), vless://, trojan://, ss://
// (param-link form), and hysteria(2)://. Anything else returns null
// from the model and we surface "Wrong Link!" the same as legacy.
function convertLink() {
const link = linkInput.value.trim();
if (!link) return;
try {
if (link.startsWith('vmess://')) {
const data = JSON.parse(atob(link.replace(/^vmess:\/\//, '')));
const ob = new Outbound(data.ps || 'vmess', Protocols.VMess);
ob.settings.address = data.add;
ob.settings.port = Number(data.port) || 443;
ob.settings.id = data.id;
ob.settings.security = data.scy || USERS_SECURITY.AUTO;
ob.stream.network = data.net || 'tcp';
if (data.tls === 'tls') ob.stream.security = 'tls';
outbound.value = ob;
message.success(t('copySuccess'));
activeKey.value = '1';
} else {
message.warning('Only vmess:// links are supported by the quick converter for now — paste full JSON in the editor instead.');
const next = Outbound.fromLink(link);
if (!next) {
message.error('Wrong Link!');
return;
}
outbound.value = next;
linkInput.value = '';
message.success('Link imported successfully...');
activeKey.value = '1';
} catch (e) {
message.error(`Link parse: ${e.message}`);
}