mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
aaaa1a015f
commit
cedc46a14d
6 changed files with 748 additions and 433 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(() => [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue