feat(frontend): inbound modal QR + tabs + restored TLS fallbacks

Per-client QR action: the qr icon on the expand-row table opened the
big info modal instead of the QR modal. Route it to QrCodeModal and
extend that modal with a `client` prop so genAllLinks() produces the
per-client share URLs (and per-peer remarks for WireGuard).

Inbound's Data redesign: split the dense single-page view into three
tabs — Inbound, Client, Subscription. Drop every QR rendering from
this modal (QrCodeModal is the QR home now). Each row in the Inbound
tab is one label/value pair instead of the legacy 2-column grid, and
long values like the VLESS encryption blob render as a wrapping code
block with a copy button so they can't blow out the dialog. The
Subscription tab renders sub URL + JSON URL as clickable anchors that
open in a new tab.

Restored TLS fallbacks UI: the model already exposed
VLESSSettings.Fallback / TrojanSettings.Fallback with addFallback /
delFallback / fallbackToJson, but the form modal never surfaced them
during the Vue 3 migration. Re-add the legacy form (SNI, ALPN, Path,
Destination, PROXY) on the protocol tab, gated on TCP transport plus
(for VLESS) encryption=none — same conditions as main.

Column widths: Protocol 70→130 and All-time Traffic 60→95 in the
inbound list; All-time Traffic 90→130 in the client expand-row, so
the header text fits and tags don't get squeezed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 19:45:14 +02:00
parent aaaa1a015f
commit cedc46a14d
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 748 additions and 433 deletions

View file

@ -407,7 +407,7 @@ function rowKey(client) {
80px /* online */ 80px /* online */
minmax(160px, 2fr) /* client identity */ minmax(160px, 2fr) /* client identity */
minmax(160px, 2fr) /* traffic */ minmax(160px, 2fr) /* traffic */
90px /* all-time */ 130px /* all-time */
140px; /* expiry */ 140px; /* expiry */
gap: 12px; gap: 12px;
align-items: center; align-items: center;

View file

@ -140,6 +140,25 @@ const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true); const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true);
const canEnableTlsFlow = computed(() => inbound.value?.canEnableTlsFlow?.() === true); const canEnableTlsFlow = computed(() => inbound.value?.canEnableTlsFlow?.() === true);
// VLESS/Trojan TLS fallbacks surfaced in the protocol tab when the
// inbound is on TCP and (for VLESS) using no Xray-side encryption.
const showFallbacks = computed(() => {
if (!inbound.value) return false;
if (inbound.value.stream?.network !== 'tcp') return false;
if (inbound.value.protocol === Protocols.VLESS) {
const enc = inbound.value.settings?.encryption;
return !enc || enc === 'none';
}
return inbound.value.protocol === Protocols.TROJAN;
});
function addFallback() {
inbound.value?.settings?.addFallback?.();
}
function delFallback(idx) {
inbound.value?.settings?.delFallback?.(idx);
}
// Date / GB bridges (legacy used moment via _expiryTime; we go direct). // Date / GB bridges (legacy used moment via _expiryTime; we go direct).
const expiryDate = computed({ const expiryDate = computed({
get: () => (dbForm.value?.expiryTime > 0 ? dayjs(dbForm.value.expiryTime) : null), get: () => (dbForm.value?.expiryTime > 0 ? dayjs(dbForm.value.expiryTime) : null),
@ -867,6 +886,107 @@ watch(
</a-form-item> </a-form-item>
</div> </div>
</a-form> </a-form>
<!-- ============== Fallbacks (VLESS/Trojan over TCP) ============== -->
<template v-if="showFallbacks">
<a-divider style="margin: 12px 0" />
<div class="fallbacks-header">
<a-tooltip
title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport."
>
<span class="fallbacks-title">
Fallbacks ({{ inbound.settings.fallbacks.length }})
</span>
</a-tooltip>
<a-button type="primary" size="small" @click="addFallback">
<template #icon><PlusOutlined /></template>
Add
</a-button>
</div>
<a-form
v-for="(fallback, idx) in inbound.settings.fallbacks"
:key="idx"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-divider style="margin: 0">
Fallback {{ idx + 1 }}
<DeleteOutlined class="danger-icon" @click="delFallback(idx)" />
</a-divider>
<a-form-item>
<template #label>
<a-tooltip title="Match TLS SNI (server name). Leave empty to match any SNI.">
SNI
</a-tooltip>
</template>
<a-input v-model:value.trim="fallback.name" placeholder="any (leave empty)" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both."
>
ALPN
</a-tooltip>
</template>
<a-select v-model:value="fallback.alpn">
<a-select-option value="">any</a-select-option>
<a-select-option value="h2">h2</a-select-option>
<a-select-option value="http/1.1">http/1.1</a-select-option>
</a-select>
</a-form-item>
<a-form-item
:validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
:help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''"
>
<template #label>
<a-tooltip
title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any."
>
Path
</a-tooltip>
</template>
<a-input v-model:value.trim="fallback.path" placeholder="any (leave empty) or /ws" />
</a-form-item>
<a-form-item
:validate-status="!fallback.dest ? 'error' : ''"
:help="!fallback.dest ? 'Destination is required' : ''"
>
<template #label>
<a-tooltip
title="Where matching traffic is forwarded. Accepts a port number (80), an addr:port (127.0.0.1:8080), or a Unix socket path (/dev/shm/x.sock or @abstract)."
>
Destination
</a-tooltip>
</template>
<a-input
v-model:value.trim="fallback.dest"
placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock"
/>
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it."
>
PROXY
</a-tooltip>
</template>
<a-select v-model:value="fallback.xver">
<a-select-option :value="0">Off</a-select-option>
<a-select-option :value="1">v1</a-select-option>
<a-select-option :value="2">v2</a-select-option>
</a-select>
</a-form-item>
</a-form>
</template>
</a-tab-pane> </a-tab-pane>
<!-- ============================== STREAM ============================== --> <!-- ============================== STREAM ============================== -->
@ -1623,6 +1743,17 @@ watch(
border-bottom: 1px solid rgba(128, 128, 128, 0.15); border-bottom: 1px solid rgba(128, 128, 128, 0.15);
} }
.fallbacks-header {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.fallbacks-title {
font-weight: 500;
flex: 1;
}
.wg-peer { .wg-peer {
margin-top: 4px; margin-top: 4px;
} }

View file

@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { CopyOutlined, SyncOutlined, DeleteOutlined } from '@ant-design/icons-vue'; import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { import {
@ -10,10 +10,10 @@ import {
SizeFormatter, SizeFormatter,
ColorUtils, ColorUtils,
ClipboardManager, ClipboardManager,
FileManager,
} from '@/utils'; } from '@/utils';
import { Inbound, Protocols } from '@/models/inbound.js'; import { Inbound, Protocols } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon.vue'; import InfinityIcon from '@/components/InfinityIcon.vue';
import QrPanel from './QrPanel.vue';
const { t } = useI18n(); const { t } = useI18n();
@ -168,6 +168,13 @@ async function copyText(value) {
if (ok) message.success(t('copied')); if (ok) message.success(t('copied'));
} }
function downloadText(content, filename) {
FileManager.downloadTextFile(content, filename);
}
// Active tab in the 3-pane layout. Reset on each open below.
const activeTab = ref('inbound');
// === Build state on open =========================================== // === Build state on open ===========================================
function genSubLink(subId) { function genSubLink(subId) {
return (props.subSettings.subURI || '') + subId; return (props.subSettings.subURI || '') + subId;
@ -180,6 +187,7 @@ watch(() => props.open, (next) => {
if (!next) return; if (!next) return;
if (!props.dbInbound) return; if (!props.dbInbound) return;
activeTab.value = 'inbound';
dbInbound.value = props.dbInbound; dbInbound.value = props.dbInbound;
inbound.value = props.dbInbound.toInbound(); inbound.value = props.dbInbound.toInbound();
@ -242,6 +250,12 @@ const securityLabel = computed(() => inbound.value?.stream?.security || 'none');
const securityColor = computed(() => (securityLabel.value === 'none' ? 'red' : 'green')); const securityColor = computed(() => (securityLabel.value === 'none' ? 'red' : 'green'));
const encryptionLabel = computed(() => inbound.value?.settings?.encryption || ''); const encryptionLabel = computed(() => inbound.value?.settings?.encryption || '');
const serverNameLabel = computed(() => inbound.value?.serverName || ''); const serverNameLabel = computed(() => inbound.value?.serverName || '');
// === Tab visibility =================================================
const showClientTab = computed(() => !!clientSettings.value);
const showSubscriptionTab = computed(
() => !!(props.subSettings.enable && clientSettings.value?.subId),
);
</script> </script>
<template> <template>
@ -253,102 +267,91 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
@cancel="close" @cancel="close"
> >
<template v-if="dbInbound && inbound"> <template v-if="dbInbound && inbound">
<!-- ============== Inbound summary ============== --> <a-tabs v-model:active-key="activeTab">
<a-row :gutter="[12, 12]"> <!-- ============================================================
<a-col :xs="24" :md="12"> TAB 1 Inbound: protocol, transport, security, per-protocol
<table class="info-table"> ============================================================== -->
<tbody> <a-tab-pane key="inbound" :tab="t('pages.xray.rules.inbound')">
<tr> <dl class="info-list">
<td>{{ t('pages.inbounds.protocol') }}</td> <div class="info-row">
<td><a-tag color="purple">{{ dbInbound.protocol }}</a-tag></td> <dt>{{ t('pages.inbounds.protocol') }}</dt>
</tr> <dd><a-tag color="purple">{{ dbInbound.protocol }}</a-tag></dd>
<tr> </div>
<td>{{ t('pages.inbounds.address') }}</td> <div class="info-row">
<td> <dt>{{ t('pages.inbounds.address') }}</dt>
<a-tooltip :title="dbInbound.address"> <dd><a-tag class="value-tag">{{ dbInbound.address }}</a-tag></dd>
<a-tag class="info-large-tag">{{ dbInbound.address }}</a-tag> </div>
</a-tooltip> <div class="info-row">
</td> <dt>{{ t('pages.inbounds.port') }}</dt>
</tr> <dd><a-tag>{{ dbInbound.port }}</a-tag></dd>
<tr> </div>
<td>{{ t('pages.inbounds.port') }}</td>
<td><a-tag>{{ dbInbound.port }}</a-tag></td>
</tr>
</tbody>
</table>
</a-col>
<a-col :xs="24" :md="12">
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS"> <template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<table class="info-table"> <div class="info-row">
<tbody> <dt>{{ t('transmission') }}</dt>
<tr> <dd><a-tag color="green">{{ networkLabel }}</a-tag></dd>
<td>{{ t('transmission') }}</td> </div>
<td><a-tag color="green">{{ networkLabel }}</a-tag></td>
</tr>
<template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP"> <template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
<tr> <div class="info-row">
<td>{{ t('host') }}</td> <dt>{{ t('host') }}</dt>
<td> <dd>
<a-tag v-if="inbound.host" class="info-large-tag">{{ inbound.host }}</a-tag> <a-tag v-if="inbound.host" class="value-tag">{{ inbound.host }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag> <a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td> </dd>
</tr> </div>
<tr> <div class="info-row">
<td>{{ t('path') }}</td> <dt>{{ t('path') }}</dt>
<td> <dd>
<a-tag v-if="inbound.path" class="info-large-tag">{{ inbound.path }}</a-tag> <a-tag v-if="inbound.path" class="value-tag">{{ inbound.path }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag> <a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td> </dd>
</tr> </div>
</template> </template>
<template v-if="inbound.isXHTTP"> <template v-if="inbound.isXHTTP">
<tr> <div class="info-row">
<td>Mode</td> <dt>Mode</dt>
<td><a-tag>{{ inbound.stream.xhttp.mode }}</a-tag></td> <dd><a-tag>{{ inbound.stream.xhttp.mode }}</a-tag></dd>
</tr> </div>
</template> </template>
<template v-if="inbound.isGrpc"> <template v-if="inbound.isGrpc">
<tr> <div class="info-row">
<td>grpc serviceName</td> <dt>grpc serviceName</dt>
<td><a-tag class="info-large-tag">{{ inbound.serviceName }}</a-tag></td> <dd><a-tag class="value-tag">{{ inbound.serviceName }}</a-tag></dd>
</tr> </div>
<tr> <div class="info-row">
<td>grpc multiMode</td> <dt>grpc multiMode</dt>
<td><a-tag>{{ inbound.stream.grpc.multiMode }}</a-tag></td> <dd><a-tag>{{ inbound.stream.grpc.multiMode }}</a-tag></dd>
</tr> </div>
</template> </template>
</tbody>
</table>
</template> </template>
</a-col>
</a-row>
<!-- ============== Security / encryption / SNI ============== --> <template v-if="dbInbound.hasLink()">
<div v-if="dbInbound.hasLink()" class="security-line"> <div class="info-row">
<span>{{ t('security') }}</span> <dt>{{ t('security') }}</dt>
<a-tag :color="securityColor">{{ securityLabel }}</a-tag> <dd><a-tag :color="securityColor">{{ securityLabel }}</a-tag></dd>
<span v-if="encryptionLabel">{{ t('encryption') }}</span> </div>
<a-tag <div v-if="encryptionLabel" class="info-row">
v-if="encryptionLabel" <dt>{{ t('encryption') }}</dt>
class="info-large-tag" <dd class="value-block">
:color="encryptionLabel !== 'none' ? 'green' : 'red'" <code class="value-code">{{ encryptionLabel }}</code>
> <a-tooltip :title="t('copy')">
{{ encryptionLabel }} <a-button size="small" class="value-copy" @click="copyText(encryptionLabel)">
</a-tag>
<a-tooltip v-if="encryptionLabel" :title="t('copy')">
<a-button size="small" @click="copyText(encryptionLabel)">
<template #icon><CopyOutlined /></template> <template #icon><CopyOutlined /></template>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<template v-if="securityLabel !== 'none'"> </dd>
<span>{{ t('domainName') }}</span>
<a-tag v-if="serverNameLabel" color="green">{{ serverNameLabel }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</template>
</div> </div>
<div v-if="securityLabel !== 'none'" class="info-row">
<dt>{{ t('domainName') }}</dt>
<dd>
<a-tag v-if="serverNameLabel" color="green" class="value-tag">{{ serverNameLabel }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</dd>
</div>
</template>
</dl>
<!-- ============== Shadowsocks single-user details ============== --> <!-- Shadowsocks single-user details -->
<table v-if="dbInbound.isSS" class="info-table block"> <table v-if="dbInbound.isSS" class="info-table block">
<tbody> <tbody>
<tr> <tr>
@ -366,9 +369,184 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
</tbody> </tbody>
</table> </table>
<!-- ============== Per-client info (multi-user) ============== --> <!-- Tunnel -->
<template v-if="clientSettings"> <table v-if="inbound.protocol === Protocols.TUNNEL" class="info-table protocol-table">
<a-divider>{{ t('pages.inbounds.client') }}</a-divider> <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>
<!-- 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>
<!-- 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>
<!-- WireGuard server config + peers -->
<table v-if="dbInbound.isWireguard" class="info-table protocol-table wg-table">
<tbody>
<tr>
<td>Secret key</td>
<td>{{ inbound.settings.secretKey }}</td>
</tr>
<tr>
<td>Public key</td>
<td>{{ inbound.settings.pubKey }}</td>
</tr>
<tr>
<td>MTU</td>
<td>{{ inbound.settings.mtu }}</td>
</tr>
<tr>
<td>No-kernel TUN</td>
<td>{{ inbound.settings.noKernelTun }}</td>
</tr>
<template v-for="(peer, idx) in inbound.settings.peers" :key="idx">
<tr>
<td colspan="2"><a-divider>Peer {{ idx + 1 }}</a-divider></td>
</tr>
<tr>
<td>Secret key</td>
<td>{{ peer.privateKey }}</td>
</tr>
<tr>
<td>Public key</td>
<td>{{ peer.publicKey }}</td>
</tr>
<tr>
<td>PSK</td>
<td>{{ peer.psk }}</td>
</tr>
<tr>
<td>Allowed IPs</td>
<td>{{ (peer.allowedIPs || []).join(',') }}</td>
</tr>
<tr>
<td>Keep alive</td>
<td>{{ peer.keepAlive }}</td>
</tr>
<tr v-if="wireguardConfigs[idx]">
<td colspan="2">
<div class="link-panel">
<div class="link-panel-header">
<a-tag color="green">Peer {{ idx + 1 }} config</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(wireguardConfigs[idx])">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('download')">
<a-button
size="small"
@click="downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)"
>
<template #icon><DownloadOutlined /></template>
</a-button>
</a-tooltip>
</div>
<code class="link-panel-text">{{ wireguardConfigs[idx] }}</code>
</div>
</td>
</tr>
<tr v-if="wireguardLinks[idx]">
<td colspan="2">
<div class="link-panel">
<div class="link-panel-header">
<a-tag color="green">Peer {{ idx + 1 }} link</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(wireguardLinks[idx])">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
</div>
<code class="link-panel-text">{{ wireguardLinks[idx] }}</code>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Single-user SS share link (no QR) -->
<template v-if="dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<div
v-for="(link, idx) in links"
:key="idx"
class="link-panel"
>
<div class="link-panel-header">
<a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(link.link)">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
</div>
<code class="link-panel-text">{{ link.link }}</code>
</div>
</template>
</a-tab-pane>
<!-- ============================================================
TAB 2 Client: per-client info + share links (no QR)
============================================================== -->
<a-tab-pane v-if="showClientTab" key="client" :tab="t('pages.inbounds.client')">
<table class="info-table block"> <table class="info-table block">
<tbody> <tbody>
<tr> <tr>
@ -468,7 +646,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
</tbody> </tbody>
</table> </table>
<!-- ============== Remaining / total / expiry ============== --> <!-- Remaining / total / expiry -->
<table class="info-table summary-table"> <table class="info-table summary-table">
<thead> <thead>
<tr> <tr>
@ -506,23 +684,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
</tbody> </tbody>
</table> </table>
<!-- ============== Subscription URLs ============== --> <!-- Telegram chat id -->
<template v-if="subSettings.enable && clientSettings.subId">
<a-divider>{{ t('subscription.title') }}</a-divider>
<QrPanel
:value="subLink"
:remark="t('subscription.title')"
:show-qr="false"
/>
<QrPanel
v-if="subSettings.subJsonEnable && subJsonLink"
:value="subJsonLink"
remark="JSON"
:show-qr="false"
/>
</template>
<!-- ============== Telegram chat id ============== -->
<template v-if="tgBotEnable && clientSettings.tgId"> <template v-if="tgBotEnable && clientSettings.tgId">
<a-divider>Telegram</a-divider> <a-divider>Telegram</a-divider>
<div class="tg-row"> <div class="tg-row">
@ -535,160 +697,66 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
</div> </div>
</template> </template>
<!-- ============== Share links + QR codes ============== --> <!-- Per-client share links (no QR) -->
<template v-if="dbInbound.hasLink() && links.length > 0"> <template v-if="dbInbound.hasLink() && links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider> <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<QrPanel <div
v-for="(link, idx) in links" v-for="(link, idx) in links"
:key="idx" :key="idx"
:value="link.link" class="link-panel"
:remark="link.remark || `Link ${idx + 1}`" >
/> <div class="link-panel-header">
</template> <a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(link.link)">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
</div>
<code class="link-panel-text">{{ link.link }}</code>
</div>
</template> </template>
</a-tab-pane>
<!-- ============== Single-user SS share link ============== --> <!-- ============================================================
<template v-else-if="dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0"> TAB 3 Subscription: clickable subscription URLs
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider> ============================================================== -->
<QrPanel <a-tab-pane v-if="showSubscriptionTab" key="subscription" :tab="t('subscription.title')">
v-for="(link, idx) in links" <div class="link-panel">
:key="idx" <div class="link-panel-header">
:value="link.link" <a-tag color="green">{{ t('subscription.title') }}</a-tag>
:remark="link.remark || `Link ${idx + 1}`" <a-tooltip :title="t('copy')">
/> <a-button size="small" @click="copyText(subLink)">
</template> <template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
</div>
<a
:href="subLink"
target="_blank"
rel="noopener noreferrer"
class="link-panel-anchor"
>{{ subLink }}</a>
</div>
<!-- ============== Tunnel ============== --> <div v-if="subSettings.subJsonEnable && subJsonLink" class="link-panel">
<table v-if="inbound.protocol === Protocols.TUNNEL" class="info-table protocol-table"> <div class="link-panel-header">
<thead> <a-tag color="green">JSON</a-tag>
<tr> <a-tooltip :title="t('copy')">
<th>{{ t('pages.inbounds.targetAddress') }}</th> <a-button size="small" @click="copyText(subJsonLink)">
<th>{{ t('pages.inbounds.destinationPort') }}</th> <template #icon><CopyOutlined /></template>
<th>{{ t('pages.inbounds.network') }}</th> </a-button>
<th>FollowRedirect</th> </a-tooltip>
</tr> </div>
</thead> <a
<tbody> :href="subJsonLink"
<tr> target="_blank"
<td><a-tag color="green">{{ inbound.settings.address }}</a-tag></td> rel="noopener noreferrer"
<td><a-tag color="green">{{ inbound.settings.port }}</a-tag></td> class="link-panel-anchor"
<td><a-tag color="green">{{ inbound.settings.network }}</a-tag></td> >{{ subJsonLink }}</a>
<td><a-tag color="green">{{ inbound.settings.followRedirect }}</a-tag></td> </div>
</tr> </a-tab-pane>
</tbody> </a-tabs>
</table>
<!-- ============== 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>
<!-- ============== 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>
<!-- ============== WireGuard ============== -->
<table v-if="dbInbound.isWireguard" class="info-table protocol-table wg-table">
<tbody>
<tr>
<td>Secret key</td>
<td>{{ inbound.settings.secretKey }}</td>
</tr>
<tr>
<td>Public key</td>
<td>{{ inbound.settings.pubKey }}</td>
</tr>
<tr>
<td>MTU</td>
<td>{{ inbound.settings.mtu }}</td>
</tr>
<tr>
<td>No-kernel TUN</td>
<td>{{ inbound.settings.noKernelTun }}</td>
</tr>
<template v-for="(peer, idx) in inbound.settings.peers" :key="idx">
<tr>
<td colspan="2"><a-divider>Peer {{ idx + 1 }}</a-divider></td>
</tr>
<tr>
<td>Secret key</td>
<td>{{ peer.privateKey }}</td>
</tr>
<tr>
<td>Public key</td>
<td>{{ peer.publicKey }}</td>
</tr>
<tr>
<td>PSK</td>
<td>{{ peer.psk }}</td>
</tr>
<tr>
<td>Allowed IPs</td>
<td>{{ (peer.allowedIPs || []).join(',') }}</td>
</tr>
<tr>
<td>Keep alive</td>
<td>{{ peer.keepAlive }}</td>
</tr>
<tr v-if="wireguardConfigs[idx]">
<td colspan="2">
<QrPanel
:value="wireguardConfigs[idx]"
:remark="`Peer ${idx + 1} config`"
:download-name="`peer-${idx + 1}.conf`"
/>
</td>
</tr>
<tr v-if="wireguardLinks[idx]">
<td colspan="2">
<QrPanel
:value="wireguardLinks[idx]"
remark="Link"
/>
</td>
</tr>
</template>
</tbody>
</table>
</template> </template>
</a-modal> </a-modal>
</template> </template>
@ -719,6 +787,65 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
display: inline-block; display: inline-block;
} }
/* Stacked label/value list one row per field. Long values wrap
* (or fall through to a code block) so they never blow out the modal. */
.info-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.info-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.info-row:last-child {
border-bottom: none;
}
.info-row dt {
margin: 0;
font-size: 13px;
opacity: 0.75;
}
.info-row dd {
margin: 0;
min-width: 0;
}
.value-tag {
max-width: 100%;
white-space: normal;
word-break: break-all;
display: inline-block;
}
.value-block {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.value-code {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
white-space: pre-wrap;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
min-width: 0;
}
:global(body.dark) .value-code {
background: rgba(255, 255, 255, 0.05);
}
.value-copy {
flex-shrink: 0;
}
.security-line { .security-line {
display: flex; display: flex;
align-items: center; align-items: center;
@ -769,4 +896,56 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
.wg-table td { .wg-table td {
word-break: break-all; word-break: break-all;
} }
/* Reusable copy/link panel that replaces QrPanel for the no-QR design. */
.link-panel {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.link-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link-panel-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
}
:global(body.dark) .link-panel-text {
background: rgba(255, 255, 255, 0.05);
}
.link-panel-anchor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
color: var(--ant-color-primary, #1677ff);
text-decoration: underline;
text-decoration-color: rgba(22, 119, 255, 0.4);
transition: background 120ms ease, text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration-color: var(--ant-color-primary, #1677ff);
}
:global(body.dark) .link-panel-anchor {
background: rgba(255, 255, 255, 0.05);
}
:global(body.dark) .link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.16);
}
</style> </style>

View file

@ -152,10 +152,10 @@ const desktopColumns = computed(() => [
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, { title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 70 }, { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
{ title: t('clients'), key: 'clients', align: 'left', width: 50 }, { title: t('clients'), key: 'clients', align: 'left', width: 50 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 60 }, { title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
]); ]);
const mobileColumns = computed(() => [ const mobileColumns = computed(() => [

View file

@ -77,6 +77,7 @@ const infoClientIndex = ref(0);
const qrOpen = ref(false); const qrOpen = ref(false);
const qrDbInbound = ref(null); const qrDbInbound = ref(null);
const qrClient = ref(null);
// === Shared text + prompt modal state ================================= // === Shared text + prompt modal state =================================
const textOpen = ref(false); const textOpen = ref(false);
@ -266,11 +267,9 @@ function onEditClient({ dbInbound, client }) {
} }
function onQrcodeClient({ dbInbound, client }) { function onQrcodeClient({ dbInbound, client }) {
// Reuse the inbound info modal focused on the chosen client that's qrDbInbound.value = checkFallback(dbInbound);
// where per-client share links and the per-link QRs live. qrClient.value = client || null;
infoDbInbound.value = checkFallback(dbInbound); qrOpen.value = true;
infoClientIndex.value = findClientIndex(dbInbound, client);
infoOpen.value = true;
} }
function onInfoClient({ dbInbound, client }) { function onInfoClient({ dbInbound, client }) {
@ -464,6 +463,7 @@ function onRowAction({ key, dbInbound }) {
break; break;
case 'qrcode': case 'qrcode':
qrDbInbound.value = checkFallback(dbInbound); qrDbInbound.value = checkFallback(dbInbound);
qrClient.value = null;
qrOpen.value = true; qrOpen.value = true;
break; break;
case 'export': case 'export':
@ -645,6 +645,7 @@ function onRowAction({ key, dbInbound }) {
<QrCodeModal <QrCodeModal
v-model:open="qrOpen" v-model:open="qrOpen"
:db-inbound="qrDbInbound" :db-inbound="qrDbInbound"
:client="qrClient"
:remark-model="remarkModel" :remark-model="remarkModel"
/> />

View file

@ -15,6 +15,7 @@ const { t } = useI18n();
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null }, dbInbound: { type: Object, default: null },
client: { type: Object, default: null },
remarkModel: { type: String, default: '-ieo' }, remarkModel: { type: String, default: '-ieo' },
}); });
@ -28,13 +29,16 @@ watch(() => props.open, (next) => {
if (!next || !props.dbInbound) return; if (!next || !props.dbInbound) return;
const inbound = props.dbInbound.toInbound(); const inbound = props.dbInbound.toInbound();
if (inbound.protocol === Protocols.WIREGUARD) { if (inbound.protocol === Protocols.WIREGUARD) {
wireguardConfigs.value = inbound.genWireguardConfigs(props.dbInbound.remark).split('\r\n'); const peerRemark = props.client?.email
wireguardLinks.value = inbound.genWireguardLinks(props.dbInbound.remark).split('\r\n'); ? `${props.dbInbound.remark}-${props.client.email}`
: props.dbInbound.remark;
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark).split('\r\n');
wireguardLinks.value = inbound.genWireguardLinks(peerRemark).split('\r\n');
links.value = []; links.value = [];
} else { } else {
// SS single-user pass null client; genAllLinks falls back to // When a client is provided we generate per-client share links;
// the inbound's settings. // otherwise (single-user SS) fall back to the inbound's settings.
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, null); links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client);
wireguardConfigs.value = []; wireguardConfigs.value = [];
wireguardLinks.value = []; wireguardLinks.value = [];
} }