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) { static fromLink(link) {
data = link.split('://'); const data = link.split('://');
if (data.length != 2) return null; if (data.length != 2) return null;
switch (data[0].toLowerCase()) { switch (data[0].toLowerCase()) {
case Protocols.VMess: case Protocols.VMess:

View file

@ -579,7 +579,10 @@ watch(
</a-tab-pane> </a-tab-pane>
<!-- ============================== PROTOCOL ============================== --> <!-- ============================== 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, <!-- Multi-user inbounds: in add mode embed the first client form,
in edit mode show a count summary. --> in edit mode show a count summary. -->
<template v-if="isMultiUser"> <template v-if="isMultiUser">
@ -643,6 +646,10 @@ watch(
</a-select> </a-select>
</a-form-item> </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-form-item label="Subscription">
<a-input v-model:value="firstClient.subId"> <a-input v-model:value="firstClient.subId">
<template #addonAfter> <template #addonAfter>

View file

@ -366,72 +366,96 @@ const showSubscriptionTab = computed(
</table> </table>
<!-- Tunnel --> <!-- Tunnel -->
<table v-if="inbound.protocol === Protocols.TUNNEL" class="info-table protocol-table"> <dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
<thead> <div class="info-row">
<tr> <dt>{{ t('pages.inbounds.targetAddress') }}</dt>
<th>{{ t('pages.inbounds.targetAddress') }}</th> <dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd>
<th>{{ t('pages.inbounds.destinationPort') }}</th> </div>
<th>{{ t('pages.inbounds.network') }}</th> <div class="info-row">
<th>FollowRedirect</th> <dt>{{ t('pages.inbounds.destinationPort') }}</dt>
</tr> <dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd>
</thead> </div>
<tbody> <div class="info-row">
<tr> <dt>{{ t('pages.inbounds.network') }}</dt>
<td><a-tag color="green">{{ inbound.settings.address }}</a-tag></td> <dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd>
<td><a-tag color="green">{{ inbound.settings.port }}</a-tag></td> </div>
<td><a-tag color="green">{{ inbound.settings.network }}</a-tag></td> <div class="info-row">
<td><a-tag color="green">{{ inbound.settings.followRedirect }}</a-tag></td> <dt>FollowRedirect</dt>
</tr> <dd>
</tbody> <a-tag :color="inbound.settings.followRedirect ? 'green' : 'red'">
</table> {{ inbound.settings.followRedirect ? t('enabled') : t('disabled') }}
</a-tag>
</dd>
</div>
</dl>
<!-- Mixed --> <!-- Mixed -->
<table v-if="dbInbound.isMixed" class="info-table protocol-table"> <dl v-if="dbInbound.isMixed" class="info-list info-list-block">
<thead> <div class="info-row">
<tr> <dt>Auth</dt>
<th>Auth</th> <dd>
<th>UDP</th> <a-tag :color="inbound.settings.auth === 'password' ? 'green' : 'orange'">
<th>IP</th> {{ inbound.settings.auth }}
</tr> </a-tag>
</thead> </dd>
<tbody> </div>
<tr> <div class="info-row">
<td><a-tag color="green">{{ inbound.settings.auth }}</a-tag></td> <dt>UDP</dt>
<td><a-tag color="green">{{ inbound.settings.udp }}</a-tag></td> <dd>
<td><a-tag color="green">{{ inbound.settings.ip }}</a-tag></td> <a-tag :color="inbound.settings.udp ? 'green' : 'red'">
</tr> {{ inbound.settings.udp ? t('enabled') : t('disabled') }}
<template v-if="inbound.settings.auth === 'password'"> </a-tag>
<tr> </dd>
<td></td> </div>
<td>{{ t('username') }}</td> <div v-if="inbound.settings.ip" class="info-row">
<td>{{ t('password') }}</td> <dt>IP</dt>
</tr> <dd><a-tag class="value-tag">{{ inbound.settings.ip }}</a-tag></dd>
<tr v-for="(account, idx) in inbound.settings.accounts" :key="idx"> </div>
<td>{{ idx }}</td> <template v-if="inbound.settings.auth === 'password' && inbound.settings.accounts?.length">
<td><a-tag color="green">{{ account.user }}</a-tag></td> <div
<td><a-tag color="green">{{ account.pass }}</a-tag></td> v-for="(account, idx) in inbound.settings.accounts"
</tr> :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> </template>
</tbody> </a-button>
</table> </a-tooltip>
</dd>
</div>
</template>
</dl>
<!-- HTTP accounts --> <!-- HTTP accounts -->
<table v-if="dbInbound.isHTTP" class="info-table protocol-table"> <dl v-if="dbInbound.isHTTP && inbound.settings.accounts?.length" class="info-list info-list-block">
<thead> <div
<tr> v-for="(account, idx) in inbound.settings.accounts"
<th></th> :key="idx"
<th>{{ t('username') }}</th> class="info-row"
<th>{{ t('password') }}</th> >
</tr> <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
</thead> <dd class="account-row">
<tbody> <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
<tr v-for="(account, idx) in inbound.settings.accounts" :key="idx"> <span class="account-sep">:</span>
<td>{{ idx }}</td> <a-tag class="value-tag">{{ account.pass }}</a-tag>
<td><a-tag color="green">{{ account.user }}</a-tag></td> <a-tooltip :title="t('copy')">
<td><a-tag color="green">{{ account.pass }}</a-tag></td> <a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
</tr> <template #icon>
</tbody> <CopyOutlined />
</table> </template>
</a-button>
</a-tooltip>
</dd>
</div>
</dl>
<!-- WireGuard server config + peers --> <!-- WireGuard server config + peers -->
<table v-if="dbInbound.isWireguard" class="info-table protocol-table wg-table"> <table v-if="dbInbound.isWireguard" class="info-table protocol-table wg-table">
@ -799,6 +823,25 @@ const showSubscriptionTab = computed(
border-bottom: none; 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 { .info-row dt {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;

View file

@ -34,6 +34,25 @@ const qrCanvas = ref(null);
let totp = 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() { function buildTotp() {
totp = new OTPAuth.TOTP({ totp = new OTPAuth.TOTP({
issuer: '3x-ui', issuer: '3x-ui',
@ -48,16 +67,19 @@ function buildTotp() {
async function paintQr() { async function paintQr() {
await nextTick(); await nextTick();
if (!qrCanvas.value || !totp) return; 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 // eslint-disable-next-line no-new
new QRious({ new QRious({
element: qrCanvas.value, element: qrCanvas.value,
size: 200, size: exactSize,
value: totp.toString(), value,
background: 'white', background: 'white',
backgroundAlpha: 0, backgroundAlpha: 1,
foreground: 'black', foreground: 'black',
padding: 2, padding: 0,
level: 'L', level: 'L',
}); });
} }
@ -145,6 +167,10 @@ async function copyToken() {
cursor: pointer; cursor: pointer;
width: 100% !important; width: 100% !important;
height: 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 { .qr-token {

View file

@ -154,29 +154,23 @@ function onOk() {
} }
// ============== Link outbound ============== // ============== Link outbound ==============
// The legacy "convert link" button takes a vmess://, vless://, ss://, // Mirrors the legacy convertLink: dispatches into Outbound.fromLink,
// trojan:// or hysteria2:// share-link string and rebuilds the // which handles vmess:// (base64 JSON), vless://, trojan://, ss://
// outbound from it. The Outbound class doesn't have a native parser // (param-link form), and hysteria(2)://. Anything else returns null
// we only support a friendly URL parse for the common shapes. // from the model and we surface "Wrong Link!" the same as legacy.
function convertLink() { function convertLink() {
const link = linkInput.value.trim(); const link = linkInput.value.trim();
if (!link) return; if (!link) return;
try { try {
if (link.startsWith('vmess://')) { const next = Outbound.fromLink(link);
const data = JSON.parse(atob(link.replace(/^vmess:\/\//, ''))); if (!next) {
const ob = new Outbound(data.ps || 'vmess', Protocols.VMess); message.error('Wrong Link!');
ob.settings.address = data.add; return;
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.');
} }
outbound.value = next;
linkInput.value = '';
message.success('Link imported successfully...');
activeKey.value = '1';
} catch (e) { } catch (e) {
message.error(`Link parse: ${e.message}`); message.error(`Link parse: ${e.message}`);
} }