mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
84b155698b
commit
085a12e469
5 changed files with 157 additions and 87 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue