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 */
minmax(160px, 2fr) /* client identity */
minmax(160px, 2fr) /* traffic */
90px /* all-time */
130px /* all-time */
140px; /* expiry */
gap: 12px;
align-items: center;

View file

@ -140,6 +140,25 @@ const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === 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).
const expiryDate = computed({
get: () => (dbForm.value?.expiryTime > 0 ? dayjs(dbForm.value.expiryTime) : null),
@ -867,6 +886,107 @@ watch(
</a-form-item>
</div>
</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>
<!-- ============================== STREAM ============================== -->
@ -1623,6 +1743,17 @@ watch(
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 {
margin-top: 4px;
}

File diff suppressed because it is too large Load diff

View file

@ -152,10 +152,10 @@ const desktopColumns = computed(() => [
{ 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.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('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 },
]);
const mobileColumns = computed(() => [

View file

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

View file

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