mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 14:14:19 +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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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