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 */
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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.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(() => [
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue