| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134 |
- <script setup>
- import { computed, ref, watch } from 'vue';
- import { useI18n } from 'vue-i18n';
- import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons-vue';
- import { message } from 'ant-design-vue';
- import {
- HttpUtil,
- IntlUtil,
- SizeFormatter,
- ColorUtils,
- ClipboardManager,
- FileManager,
- } from '@/utils';
- import { Protocols } from '@/models/inbound.js';
- import InfinityIcon from '@/components/InfinityIcon.vue';
- import { useDatepicker } from '@/composables/useDatepicker.js';
- const { t } = useI18n();
- const { datepicker } = useDatepicker();
- const props = defineProps({
- open: { type: Boolean, default: false },
- dbInbound: { type: Object, default: null },
- clientIndex: { type: Number, default: 0 },
- remarkModel: { type: String, default: '-ieo' },
- expireDiff: { type: Number, default: 0 },
- trafficDiff: { type: Number, default: 0 },
- ipLimitEnable: { type: Boolean, default: false },
- tgBotEnable: { type: Boolean, default: false },
- nodeAddress: { type: String, default: '' },
- subSettings: {
- type: Object,
- default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
- },
- lastOnlineMap: { type: Object, default: () => ({}) },
- });
- const emit = defineEmits(['update:open']);
- // Cloned state on open so cancel doesn't leak edits onto the row's
- // parsed-cache copy. The local ref intentionally shadows the prop —
- // templates read this ref's frozen-on-open value, not props.dbInbound.
- // eslint-disable-next-line vue/no-dupe-keys
- const dbInbound = ref(null);
- const inbound = ref(null);
- const clientSettings = ref(null);
- const clientStats = ref(null);
- const links = ref([]); // generic share links (for VMess/VLess/Trojan/SS/Hysteria)
- const wireguardConfigs = ref([]); // multi-line .conf bodies (one per peer)
- const wireguardLinks = ref([]); // wg:// share URIs (one per peer)
- const subLink = ref('');
- const subJsonLink = ref('');
- // IP-log state (matches the legacy refresh / clear flow).
- const refreshing = ref(false);
- const clientIpsArray = ref([]);
- const clientIpsText = ref('');
- // === Status flags shown as tags ====================================
- const isEnable = computed(() => {
- if (clientSettings.value) return !!clientSettings.value.enable;
- return dbInbound.value?.enable ?? true;
- });
- const isDepleted = computed(() => {
- const stats = clientStats.value;
- const settings = clientSettings.value;
- if (!stats || !settings) return false;
- const total = stats.total ?? 0;
- const used = (stats.up ?? 0) + (stats.down ?? 0);
- if (total > 0 && used >= total) return true;
- const expiry = settings.expiryTime ?? 0;
- if (expiry > 0 && Date.now() >= expiry) return true;
- return false;
- });
- function statsColor(stats) {
- return ColorUtils.usageColor(stats.up + stats.down, props.trafficDiff, stats.total);
- }
- function getRemainingStats() {
- if (!clientStats.value || !clientSettings.value) return '-';
- const remained = clientStats.value.total - clientStats.value.up - clientStats.value.down;
- return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
- }
- function formatLastOnline(email) {
- const ts = props.lastOnlineMap[email];
- if (!ts) return '-';
- return IntlUtil.formatDate(ts, datepicker.value);
- }
- // === IP log ========================================================
- function formatIpInfo(record) {
- if (record == null) return '';
- if (typeof record === 'string' || typeof record === 'number') return String(record);
- const ip = record.ip || record.IP || '';
- const ts = record.timestamp || record.Timestamp || 0;
- if (!ip) return String(record);
- if (!ts) return String(ip);
- const date = new Date(Number(ts) * 1000);
- const timeStr = date
- .toLocaleString('en-GB', {
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- hour12: false,
- })
- .replace(',', '');
- return `${ip} (${timeStr})`;
- }
- async function loadClientIps() {
- if (!clientStats.value?.email) return;
- refreshing.value = true;
- try {
- const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.value.email}`);
- if (!msg?.success) {
- clientIpsText.value = msg?.obj || 'No IP record';
- clientIpsArray.value = [];
- return;
- }
- let ips = msg.obj;
- if (typeof ips === 'string') {
- try { ips = JSON.parse(ips); }
- catch (_e) { clientIpsText.value = String(ips); clientIpsArray.value = [String(ips)]; return; }
- }
- if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips];
- if (Array.isArray(ips) && ips.length > 0) {
- const arr = ips.map(formatIpInfo).filter(Boolean);
- clientIpsArray.value = arr;
- clientIpsText.value = arr.join(' | ');
- } else {
- clientIpsArray.value = [];
- clientIpsText.value = String(ips || t('tgbot.noIpRecord'));
- }
- } finally {
- refreshing.value = false;
- }
- }
- async function clearClientIps() {
- if (!clientStats.value?.email) return;
- const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.value.email}`);
- if (msg?.success) {
- clientIpsArray.value = [];
- clientIpsText.value = t('tgbot.noIpRecord');
- }
- }
- async function copyText(value) {
- const ok = await ClipboardManager.copyText(String(value ?? ''));
- if (ok) message.success(t('copied'));
- }
- function downloadText(content, filename) {
- FileManager.downloadTextFile(content, filename);
- }
- const activeTab = ref('client');
- // === Build state on open ===========================================
- function genSubLink(subId) {
- return (props.subSettings.subURI || '') + subId;
- }
- function genSubJsonLink(subId) {
- return (props.subSettings.subJsonURI || '') + subId;
- }
- watch(() => props.open, (next) => {
- if (!next) return;
- if (!props.dbInbound) return;
- activeTab.value = props.dbInbound.toInbound().clients?.length ? 'client' : 'inbound';
- dbInbound.value = props.dbInbound;
- inbound.value = props.dbInbound.toInbound();
- const idx = props.clientIndex ?? 0;
- if (inbound.value.clients?.length) {
- clientSettings.value = inbound.value.clients[idx] || null;
- } else {
- clientSettings.value = null;
- }
- clientStats.value = clientSettings.value
- ? (props.dbInbound.clientStats || []).find((s) => s.email === clientSettings.value.email) || null
- : null;
- // Generate links per protocol — WireGuard has its own .conf body
- // path; everything else flows through genAllLinks.
- if (inbound.value.protocol === Protocols.WIREGUARD) {
- wireguardConfigs.value = inbound.value.genWireguardConfigs(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n');
- wireguardLinks.value = inbound.value.genWireguardLinks(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n');
- links.value = [];
- } else {
- links.value = inbound.value.genAllLinks(
- props.dbInbound.remark,
- props.remarkModel,
- clientSettings.value,
- props.nodeAddress,
- );
- wireguardConfigs.value = [];
- wireguardLinks.value = [];
- }
- // Subscription link is per-client because each client has its own subId.
- if (clientSettings.value?.subId) {
- subLink.value = genSubLink(clientSettings.value.subId);
- subJsonLink.value = props.subSettings.subJsonEnable
- ? genSubJsonLink(clientSettings.value.subId)
- : '';
- } else {
- subLink.value = '';
- subJsonLink.value = '';
- }
- // Auto-load IP log if it'll be visible.
- clientIpsArray.value = [];
- clientIpsText.value = '';
- if (
- props.ipLimitEnable
- && clientSettings.value?.limitIp > 0
- && clientStats.value?.email
- ) {
- loadClientIps();
- }
- });
- function close() {
- emit('update:open', false);
- }
- // === Convenience displays ===========================================
- const networkLabel = computed(() => inbound.value?.stream?.network || '');
- const securityLabel = computed(() => inbound.value?.stream?.security || 'none');
- const securityColor = computed(() => (securityLabel.value === 'none' ? 'red' : 'green'));
- const encryptionLabel = computed(() => inbound.value?.settings?.encryption || '');
- const serverNameLabel = computed(() => inbound.value?.serverName || '');
- // === Tab visibility =================================================
- const showClientTab = computed(() => !!clientSettings.value);
- const showSubscriptionTab = computed(
- () => !!(props.subSettings.enable && clientSettings.value?.subId),
- );
- </script>
- <template>
- <a-modal :open="open" :title="t('pages.inbounds.inboundData')" :footer="null" width="640px" @cancel="close">
- <template v-if="dbInbound && inbound">
- <a-tabs v-model:active-key="activeTab">
- <!-- ============================================================
- TAB 1 — Client: per-client info + share links + subscription
- (subscription is folded in here so users don't need a third
- tab — the sub URLs are per-client anyway).
- ============================================================== -->
- <a-tab-pane v-if="showClientTab" key="client" :tab="t('pages.inbounds.client')">
- <table class="info-table block">
- <tbody>
- <tr>
- <td>{{ t('pages.inbounds.email') }}</td>
- <td>
- <a-tag v-if="clientSettings.email" color="green">{{ clientSettings.email }}</a-tag>
- <a-tag v-else color="red">{{ t('none') }}</a-tag>
- </td>
- </tr>
- <tr v-if="clientSettings.id">
- <td>ID</td>
- <td><a-tag>{{ clientSettings.id }}</a-tag></td>
- </tr>
- <tr v-if="dbInbound.isVMess">
- <td>{{ t('security') }}</td>
- <td><a-tag>{{ clientSettings.security }}</a-tag></td>
- </tr>
- <tr v-if="inbound.canEnableTlsFlow()">
- <td>Flow</td>
- <td>
- <a-tag v-if="clientSettings.flow">{{ clientSettings.flow }}</a-tag>
- <a-tag v-else color="orange">{{ t('none') }}</a-tag>
- </td>
- </tr>
- <tr v-if="clientSettings.password">
- <td>{{ t('password') }}</td>
- <td><a-tag class="info-large-tag">{{ clientSettings.password }}</a-tag></td>
- </tr>
- <tr>
- <td>{{ t('status') }}</td>
- <td>
- <a-tag v-if="isDepleted" color="red">{{ t('depleted') }}</a-tag>
- <a-tag v-else-if="isEnable" color="green">{{ t('enabled') }}</a-tag>
- <a-tag v-else>{{ t('disabled') }}</a-tag>
- </td>
- </tr>
- <tr v-if="clientStats">
- <td>{{ t('usage') }}</td>
- <td>
- <a-tag color="green">
- {{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }}
- </a-tag>
- <a-tag>
- ↑ {{ SizeFormatter.sizeFormat(clientStats.up) }} /
- {{ SizeFormatter.sizeFormat(clientStats.down) }} ↓
- </a-tag>
- </td>
- </tr>
- <tr>
- <td>{{ t('pages.inbounds.createdAt') }}</td>
- <td>
- <a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker)
- }}</a-tag>
- <a-tag v-else>-</a-tag>
- </td>
- </tr>
- <tr>
- <td>{{ t('pages.inbounds.updatedAt') }}</td>
- <td>
- <a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker)
- }}</a-tag>
- <a-tag v-else>-</a-tag>
- </td>
- </tr>
- <tr>
- <td>{{ t('lastOnline') }}</td>
- <td><a-tag>{{ formatLastOnline(clientSettings.email || '') }}</a-tag></td>
- </tr>
- <tr v-if="clientSettings.comment">
- <td>{{ t('comment') }}</td>
- <td><a-tag class="info-large-tag">{{ clientSettings.comment }}</a-tag></td>
- </tr>
- <tr v-if="ipLimitEnable">
- <td>{{ t('pages.inbounds.IPLimit') }}</td>
- <td><a-tag>{{ clientSettings.limitIp }}</a-tag></td>
- </tr>
- <tr v-if="ipLimitEnable && clientSettings.limitIp > 0">
- <td>{{ t('pages.inbounds.IPLimitlog') }}</td>
- <td>
- <div class="ip-log">
- <div v-if="clientIpsArray.length > 0">
- <a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
- }}</a-tag>
- </div>
- <a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
- </div>
- <div class="ip-log-actions">
- <SyncOutlined :spin="refreshing" @click="loadClientIps" />
- <a-tooltip :title="t('pages.inbounds.IPLimitlogclear')">
- <DeleteOutlined @click="clearClientIps" />
- </a-tooltip>
- </div>
- </td>
- </tr>
- </tbody>
- </table>
- <!-- Remaining / total / expiry -->
- <table class="info-table summary-table">
- <thead>
- <tr>
- <th>{{ t('remained') }}</th>
- <th>{{ t('pages.inbounds.totalUsage') }}</th>
- <th>{{ t('pages.inbounds.expireDate') }}</th>
- </tr>
- </thead>
- <tbody>
- <tr>
- <td>
- <a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
- getRemainingStats() }}</a-tag>
- <a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
- <InfinityIcon />
- </a-tag>
- </td>
- <td>
- <a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
- SizeFormatter.sizeFormat(clientSettings.totalGB) }}</a-tag>
- <a-tag v-else color="purple">
- <InfinityIcon />
- </a-tag>
- </td>
- <td>
- <a-tag v-if="clientSettings.expiryTime > 0"
- :color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)">{{
- IntlUtil.formatDate(clientSettings.expiryTime, datepicker) }}</a-tag>
- <a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
- {{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
- </a-tag>
- <a-tag v-else color="purple">
- <InfinityIcon />
- </a-tag>
- </td>
- </tr>
- </tbody>
- </table>
- <!-- Telegram chat id -->
- <template v-if="tgBotEnable && clientSettings.tgId">
- <a-divider>Telegram</a-divider>
- <div class="tg-row">
- <a-tag color="blue">{{ clientSettings.tgId }}</a-tag>
- <a-tooltip :title="t('copy')">
- <a-button size="small" @click="copyText(clientSettings.tgId)">
- <template #icon>
- <CopyOutlined />
- </template>
- </a-button>
- </a-tooltip>
- </div>
- </template>
- <!-- Per-client share links (no QR) -->
- <template v-if="dbInbound.hasLink() && 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>
- <!-- Subscription URLs — folded into the client tab so they sit
- with the rest of the per-client data. Only visible when
- subscriptions are enabled and this client has a subId. -->
- <template v-if="showSubscriptionTab">
- <a-divider>{{ t('subscription.title') }}</a-divider>
- <div class="link-panel">
- <div class="link-panel-header">
- <a-tag color="green">{{ t('subscription.title') }}</a-tag>
- <a-tooltip :title="t('copy')">
- <a-button size="small" @click="copyText(subLink)">
- <template #icon>
- <CopyOutlined />
- </template>
- </a-button>
- </a-tooltip>
- </div>
- <a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
- </div>
- <div v-if="subSettings.subJsonEnable && subJsonLink" class="link-panel">
- <div class="link-panel-header">
- <a-tag color="green">JSON</a-tag>
- <a-tooltip :title="t('copy')">
- <a-button size="small" @click="copyText(subJsonLink)">
- <template #icon>
- <CopyOutlined />
- </template>
- </a-button>
- </a-tooltip>
- </div>
- <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
- }}</a>
- </div>
- </template>
- </a-tab-pane>
- <!-- ============================================================
- TAB 2 — Inbound: protocol, transport, security, per-protocol
- ============================================================== -->
- <a-tab-pane key="inbound" :tab="t('pages.xray.rules.inbound')">
- <dl class="info-list">
- <div class="info-row">
- <dt>{{ t('pages.inbounds.protocol') }}</dt>
- <dd><a-tag color="purple">{{ dbInbound.protocol }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>{{ t('pages.inbounds.address') }}</dt>
- <dd><a-tag class="value-tag">{{ dbInbound.address }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>{{ t('pages.inbounds.port') }}</dt>
- <dd><a-tag>{{ dbInbound.port }}</a-tag></dd>
- </div>
- <template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
- <div class="info-row">
- <dt>{{ t('transmission') }}</dt>
- <dd><a-tag color="green">{{ networkLabel }}</a-tag></dd>
- </div>
- <template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
- <div class="info-row">
- <dt>{{ t('host') }}</dt>
- <dd>
- <a-tag v-if="inbound.host" class="value-tag">{{ inbound.host }}</a-tag>
- <a-tag v-else color="orange">{{ t('none') }}</a-tag>
- </dd>
- </div>
- <div class="info-row">
- <dt>{{ t('path') }}</dt>
- <dd>
- <a-tag v-if="inbound.path" class="value-tag">{{ inbound.path }}</a-tag>
- <a-tag v-else color="orange">{{ t('none') }}</a-tag>
- </dd>
- </div>
- </template>
- <template v-if="inbound.isXHTTP">
- <div class="info-row">
- <dt>Mode</dt>
- <dd><a-tag>{{ inbound.stream.xhttp.mode }}</a-tag></dd>
- </div>
- </template>
- <template v-if="inbound.isGrpc">
- <div class="info-row">
- <dt>grpc serviceName</dt>
- <dd><a-tag class="value-tag">{{ inbound.serviceName }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>grpc multiMode</dt>
- <dd><a-tag>{{ inbound.stream.grpc.multiMode }}</a-tag></dd>
- </div>
- </template>
- </template>
- <template v-if="dbInbound.hasLink()">
- <div class="info-row">
- <dt>{{ t('security') }}</dt>
- <dd><a-tag :color="securityColor">{{ securityLabel }}</a-tag></dd>
- </div>
- <div v-if="encryptionLabel" class="info-row">
- <dt>{{ t('encryption') }}</dt>
- <dd class="value-block">
- <code class="value-code">{{ encryptionLabel }}</code>
- <a-tooltip :title="t('copy')">
- <a-button size="small" class="value-copy" @click="copyText(encryptionLabel)">
- <template #icon>
- <CopyOutlined />
- </template>
- </a-button>
- </a-tooltip>
- </dd>
- </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 -->
- <table v-if="dbInbound.isSS" class="info-table block">
- <tbody>
- <tr>
- <td>{{ t('encryption') }}</td>
- <td><a-tag color="green">{{ inbound.settings.method }}</a-tag></td>
- </tr>
- <tr v-if="inbound.isSS2022">
- <td>{{ t('password') }}</td>
- <td><a-tag class="info-large-tag">{{ inbound.settings.password }}</a-tag></td>
- </tr>
- <tr>
- <td>{{ t('pages.inbounds.network') }}</td>
- <td><a-tag color="green">{{ inbound.settings.network }}</a-tag></td>
- </tr>
- </tbody>
- </table>
- <!-- TUN -->
- <dl v-if="inbound.protocol === Protocols.TUN" class="info-list info-list-block">
- <div class="info-row">
- <dt>Interface name</dt>
- <dd><a-tag color="green" class="value-tag">{{ inbound.settings.name }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>MTU</dt>
- <dd><a-tag color="green">{{ inbound.settings.mtu }}</a-tag></dd>
- </div>
- <div v-if="inbound.settings.gateway?.length" class="info-row">
- <dt>Gateway</dt>
- <dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
- class="value-tag">{{
- ip }}</a-tag></dd>
- </div>
- <div v-if="inbound.settings.dns?.length" class="info-row">
- <dt>DNS</dt>
- <dd><a-tag v-for="(ip, j) in inbound.settings.dns" :key="`tun-i-dns-${j}`" color="green">{{ ip }}</a-tag>
- </dd>
- </div>
- <div class="info-row">
- <dt>Outbounds interface</dt>
- <dd><a-tag color="green">{{ inbound.settings.autoOutboundsInterface || 'auto' }}</a-tag></dd>
- </div>
- <div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
- <dt>Auto system routes</dt>
- <dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
- color="green">{{
- cidr }}</a-tag></dd>
- </div>
- </dl>
- <!-- Tunnel -->
- <dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
- <div class="info-row">
- <dt>{{ t('pages.inbounds.targetAddress') }}</dt>
- <dd><a-tag color="green" class="value-tag">{{ inbound.settings.rewriteAddress }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>{{ t('pages.inbounds.destinationPort') }}</dt>
- <dd><a-tag color="green">{{ inbound.settings.rewritePort }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>{{ t('pages.inbounds.network') }}</dt>
- <dd><a-tag color="green">{{ inbound.settings.allowedNetwork }}</a-tag></dd>
- </div>
- <div class="info-row">
- <dt>FollowRedirect</dt>
- <dd>
- <a-tag :color="inbound.settings.followRedirect ? 'green' : 'red'">
- {{ inbound.settings.followRedirect ? t('enabled') : t('disabled') }}
- </a-tag>
- </dd>
- </div>
- </dl>
- <!-- Mixed -->
- <dl v-if="dbInbound.isMixed" class="info-list info-list-block">
- <div class="info-row">
- <dt>Auth</dt>
- <dd>
- <a-tag :color="inbound.settings.auth === 'password' ? 'green' : 'orange'">
- {{ inbound.settings.auth }}
- </a-tag>
- </dd>
- </div>
- <div class="info-row">
- <dt>UDP</dt>
- <dd>
- <a-tag :color="inbound.settings.udp ? 'green' : 'red'">
- {{ inbound.settings.udp ? t('enabled') : t('disabled') }}
- </a-tag>
- </dd>
- </div>
- <div v-if="inbound.settings.ip" class="info-row">
- <dt>IP</dt>
- <dd><a-tag class="value-tag">{{ inbound.settings.ip }}</a-tag></dd>
- </div>
- <template v-if="inbound.settings.auth === 'password' && inbound.settings.accounts?.length">
- <div v-for="(account, idx) in inbound.settings.accounts" :key="idx" class="info-row">
- <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
- <dd class="account-row">
- <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
- <span class="account-sep">:</span>
- <a-tag class="value-tag">{{ account.pass }}</a-tag>
- <a-tooltip :title="t('copy')">
- <a-button size="small" type="text"
- @click="copyText(`${account.user}:${account.pass}`)">
- <template #icon><CopyOutlined /></template>
- </a-button>
- </a-tooltip>
- <a-space :size="4" wrap class="share-buttons share-desktop">
- <a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
- <a-button size="small"
- @click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
- SOCKS5
- </a-button>
- </a-tooltip>
- <a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
- <a-button size="small"
- @click="copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
- HTTP
- </a-button>
- </a-tooltip>
- <a-tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
- <a-button size="small"
- @click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`)">
- Telegram
- </a-button>
- </a-tooltip>
- </a-space>
- <a-dropdown :trigger="['click']" class="share-mobile">
- <a-button size="small">
- <template #icon><CopyOutlined /></template>
- {{ t('copy') }}
- </a-button>
- <template #overlay>
- <a-menu @click="({ key }) => {
- const h = dbInbound.address;
- const port = dbInbound.port;
- if (key === 'telegram') {
- copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`);
- } else {
- copyText(`${key}://${h}:${port}@${account.user}:${account.pass}`);
- }
- }">
- <a-menu-item key="socks5">SOCKS5</a-menu-item>
- <a-menu-item key="http">HTTP</a-menu-item>
- <a-menu-item key="telegram">Telegram</a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- </dd>
- </div>
- </template>
- <template v-if="inbound.settings.auth === 'noauth'">
- <div class="info-row">
- <dt>{{ t('copy') }}</dt>
- <dd>
- <a-space :size="4" wrap class="share-buttons share-desktop">
- <a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}`">
- <a-button size="small"
- @click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}`)">
- SOCKS5
- </a-button>
- </a-tooltip>
- <a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}`">
- <a-button size="small"
- @click="copyText(`http://${dbInbound.address}:${dbInbound.port}`)">
- HTTP
- </a-button>
- </a-tooltip>
- <a-tooltip title="https://t.me/socks?server=...&port=...">
- <a-button size="small"
- @click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}`)">
- Telegram
- </a-button>
- </a-tooltip>
- </a-space>
- <a-dropdown :trigger="['click']" class="share-mobile">
- <a-button size="small">
- <template #icon><CopyOutlined /></template>
- {{ t('copy') }}
- </a-button>
- <template #overlay>
- <a-menu @click="({ key }) => {
- const h = dbInbound.address;
- const port = dbInbound.port;
- if (key === 'telegram') {
- copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}`);
- } else {
- copyText(`${key}://${h}:${port}`);
- }
- }">
- <a-menu-item key="socks5">SOCKS5</a-menu-item>
- <a-menu-item key="http">HTTP</a-menu-item>
- <a-menu-item key="telegram">Telegram</a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- </dd>
- </div>
- </template>
- </dl>
- <!-- HTTP accounts -->
- <dl v-if="dbInbound.isHTTP && inbound.settings.accounts?.length" class="info-list info-list-block">
- <div v-for="(account, idx) in inbound.settings.accounts" :key="idx" class="info-row">
- <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
- <dd class="account-row">
- <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
- <span class="account-sep">:</span>
- <a-tag class="value-tag">{{ account.pass }}</a-tag>
- <a-tooltip :title="t('copy')">
- <a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
- <template #icon>
- <CopyOutlined />
- </template>
- </a-button>
- </a-tooltip>
- </dd>
- </div>
- </dl>
- <!-- 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>
- </a-tabs>
- </template>
- </a-modal>
- </template>
- <style scoped>
- .info-table {
- width: 100%;
- border-collapse: collapse;
- }
- .info-table.block {
- margin-bottom: 10px;
- }
- .info-table td,
- .info-table th {
- padding: 4px 8px;
- vertical-align: top;
- }
- .info-table th {
- text-align: center;
- font-weight: 500;
- }
- .info-large-tag {
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- 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;
- }
- /* When info-list is rendered as a second block (e.g. protocol details
- * after the top transport/security block), give it a small top spacing
- * so the two groups read as separate. */
- .info-list-block {
- margin-top: 10px;
- }
- .account-row {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-wrap: wrap;
- }
- .account-sep {
- opacity: 0.55;
- font-weight: 600;
- }
- .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;
- margin-right: 0;
- }
- .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;
- }
- .share-buttons,
- .share-mobile {
- margin-inline-start: 4px;
- padding-inline-start: 8px;
- border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
- }
- .share-mobile {
- display: none;
- }
- @media (max-width: 600px) {
- .share-desktop {
- display: none !important;
- }
- .share-mobile {
- display: inline-flex;
- align-items: center;
- }
- }
- .security-line {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- margin: 8px 0;
- }
- .security-line span {
- font-size: 13px;
- opacity: 0.75;
- }
- .summary-table {
- width: 100%;
- text-align: center;
- margin: 10px 0;
- }
- .tg-row {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .ip-log {
- max-height: 150px;
- overflow-y: auto;
- text-align: left;
- }
- .ip-log-row {
- display: block;
- margin: 2px 0;
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 11px;
- }
- .ip-log-actions {
- display: flex;
- gap: 12px;
- margin-top: 5px;
- font-size: 16px;
- cursor: pointer;
- }
- .protocol-table {
- margin-top: 10px;
- }
- .wg-table td {
- 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>
|