Y-Panel/web/html/inbounds.html
心隨緣動 4354efb974
Some checks failed
Release X-Panel / Build for Linux (386) (push) Has been cancelled
Release X-Panel / Build for Linux (amd64) (push) Has been cancelled
Release X-Panel / Build for Linux (arm64) (push) Has been cancelled
Release X-Panel / Build for Linux (armv5) (push) Has been cancelled
Release X-Panel / Build for Linux (armv6) (push) Has been cancelled
Release X-Panel / Build for Linux (armv7) (push) Has been cancelled
Release X-Panel / Build for Linux (s390x) (push) Has been cancelled
Release X-Panel / Build for Windows (386) (push) Has been cancelled
Release X-Panel / Build for Windows (amd64) (push) Has been cancelled
One-click configuration adds protocol combination: VLESS + XHTTP + Reality
2025-10-09 21:47:07 +08:00

2586 lines
134 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{ template "page/head_start" .}}
<style>
.ant-table:not(.ant-table-expanded-row .ant-table) {
outline: 1px solid #f0f0f0;
outline-offset: -1px;
border-radius: 1rem;
overflow-x: hidden;
}
// 新增: 确保表格标题不换行
.ant-table-thead > tr > th {
white-space: nowrap;
}
.dark .ant-table:not(.ant-table-expanded-row .ant-table) {
outline-color: var(--dark-color-table-ring);
}
.ant-modal-body .ant-input-group.ant-input-group-compact {
display: block;
}
.ant-modal-body .ant-input-group.ant-input-group-compact .ant-input-affix-wrapper,
.ant-modal-body .ant-input-group.ant-input-group-compact .ant-input-search-button {
width: 100%;
}
.ant-modal-body .ant-input-group.ant-input-group-compact .ant-input {
white-space: normal;
height: auto;
min-height: 80px; /* 调整此值以控制显示行数 */
}
.ant-table .ant-table-content .ant-table-scroll .ant-table-body {
overflow-y: hidden;
}
.ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper {
margin:-10px 22px !important;
}
.ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper .ant-table {
border-bottom-left-radius: 1rem;
border-bottom-right-radius: 1rem;
}
.ant-table .ant-table-content .ant-table-tbody tr:last-child tr:last-child td {
border-bottom-color: transparent;
}
.ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:first-child {
border-bottom-left-radius: 6px;
}
.ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:last-child {
border-bottom-right-radius: 6px;
}
@media (min-width: 769px) {
.ant-layout-content {
margin: 24px 16px;
}
}
@media (max-width: 768px) {
.ant-card-body {
padding: .5rem;
}
.ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper {
margin:-10px 2px !important;
}
}
.dark .ant-switch-small:not(.ant-switch-checked) {
background-color: var(--dark-color-surface-100) !important;
}
.ant-custom-popover-title {
display: flex;
align-items: center;
gap: 10px;
margin: 5px 0;
}
.ant-col-sm-24 {
margin: 0.5rem -2rem 0.5rem 2rem;
}
tr.hideExpandIcon .ant-table-row-expand-icon {
display: none;
}
.infinite-tag {
padding: 0 5px;
border-radius: 2rem;
min-width: 50px;
min-height: 22px;
}
.infinite-bar .ant-progress-inner .ant-progress-bg {
background-color: #F2EAF1;
border: #D5BED2 solid 1px;
}
.dark .infinite-bar .ant-progress-inner .ant-progress-bg {
background-color: #7a316f !important;
border: #7a316f solid 1px;
}
.ant-collapse {
margin: 5px 0;
}
.info-large-tag {
max-width: 200px;
overflow: hidden;
}
.client-comment {
font-size: 12px;
opacity: 0.75;
cursor: help;
}
.client-email {
font-weight: 500;
}
.client-popup-item {
display: flex;
align-items: center;
gap: 5px;
}
.online-animation .ant-badge-status-dot {
animation: onlineAnimation 1.2s linear infinite;
}
@keyframes onlineAnimation {
0%,
50%,
100% {
transform: scale(1);
opacity: 1;
}
10% {
transform: scale(1.5);
opacity: .2;
}
}
.tr-table-box {
display: flex;
gap: 4px;
justify-content: center;
align-items: center;
}
.tr-table-rt {
flex-basis: 70px;
min-width: 70px;
text-align: end;
}
.tr-table-lt {
flex-basis: 70px;
min-width: 70px;
text-align: start;
}
.tr-table-bar {
flex-basis: 160px;
min-width: 60px;
}
.tr-infinity-ch {
font-size: 14pt;
max-height: 24px;
display: inline-flex;
align-items: center;
}
.ant-table-expanded-row .ant-table .ant-table-body {
overflow-x: hidden;
}
.ant-table-expanded-row .ant-table-tbody>tr>td {
padding: 10px 2px;
}
.ant-table-expanded-row .ant-table-thead>tr>th {
padding: 12px 2px;
}
</style>
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}'
color="red"
description='{{ i18n "secAlertSsl" }}'
show-icon closable>
</a-alert>
</transition>
<transition name="list" appear>
<a-row v-if="!loadingStates.fetched">
<a-card :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<a-row>
<a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.totalDownUp" }}' :value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`">
<template #prefix>
<a-icon type="swap"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.totalUsage" }}' :value="SizeFormatter.sizeFormat(total.up + total.down)" :style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-icon type="pie-chart"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}' :value="SizeFormatter.sizeFormat(total.allTime)" :style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-icon type="history"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.inboundCount" }}' :value="dbInbounds.length" :style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-icon type="bars"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="4">
<a-custom-statistic title='{{ i18n "clients" }}' value=" " :style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-space direction="horizontal">
<a-icon type="team"></a-icon>
<div>
<a-back-top :target="() => document.getElementById('content-layout')" visibility-height="200"></a-back-top>
<a-tag color="green">[[ total.clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in total.deactive"><span>[[ clientEmail ]]</span></div>
</template>
<a-tag v-if="total.deactive.length">[[ total.deactive.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in total.depleted"><span>[[ clientEmail ]]</span></div>
</template>
<a-tag color="red" v-if="total.depleted.length">[[ total.depleted.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in total.expiring"><span>[[ clientEmail ]]</span></div>
</template>
<a-tag color="orange" v-if="total.expiring.length">[[ total.expiring.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in onlineClients"><span>[[ clientEmail ]]</span></div>
</template>
<a-tag color="blue" v-if="onlineClients.length">[[ onlineClients.length ]]</a-tag>
</a-popover>
</div>
</a-space>
</template>
</a-custom-statistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col>
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<a-button type="primary" icon="plus" @click="openAddInbound">
<template v-if="!isMobile">{{ i18n "pages.inbounds.addInbound" }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary" icon="menu">
<template v-if="!isMobile">{{ i18n "pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="import">
<a-icon type="import"></a-icon>
{{ i18n "pages.inbounds.importInbound" }}
</a-menu-item>
<a-menu-item key="export">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }}
</a-menu-item>
<a-menu-item key="subs" v-if="subSettings.enable">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item>
<a-menu-item key="resetInbounds">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetAllTraffic" }}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu>
</a-dropdown>
<a-button type="primary" icon="thunderbolt" @click="openOneClickConfig">
<template v-if="!isMobile">{{ i18n "pages.inbounds.oneClickConfig" }}</template>
</a-button>
<a-button type="primary" icon="link" @click="handleSubscriptionConversion">
<template v-if="!isMobile">{{ i18n "pages.inbounds.is_subConversion" }}</template>
</a-button>
</a-space>
</template>
<template #extra>
<a-button-group>
<a-button icon="sync" @click="manualRefresh" :loading="refreshing"></a-button>
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
<span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
<a-select v-model="refreshInterval"
:disabled="!isRefreshEnabled"
:style="{ width: '100%' }"
@change="changeRefreshInterval"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select>
</a-space>
</template>
<a-button icon="down"></a-button>
</a-popover>
</a-button-group>
</template>
<a-space direction="vertical">
<div :style="isMobile ? {} : { display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }">
<a-switch v-model="enableFilter"
:style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
@change="toggleFilter">
<a-icon slot="checkedChildren" type="search"></a-icon>
<a-icon slot="unCheckedChildren" type="filter"></a-icon>
</a-switch>
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid" :size="isMobile ? 'small' : ''">
<a-radio-button value="">{{ i18n "none" }}</a-radio-button>
<a-radio-button value="deactive">{{ i18n "disabled" }}</a-radio-button>
<a-radio-button value="depleted">{{ i18n "depleted" }}</a-radio-button>
<a-radio-button value="expiring">{{ i18n "depletingSoon" }}</a-radio-button>
<a-radio-button value="online">{{ i18n "online" }}</a-radio-button>
</a-radio-group>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="dbInbound => dbInbound.id"
:data-source="searchedInbounds"
:scroll="isMobile ? {} : { x: 1000 }"
:pagination=pagination(searchedInbounds)
:expand-icon-as-cell="false"
:expand-row-by-click="false"
:expand-icon-column-index="0"
:indent-size="0"
:row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')"
:style="{ marginTop: '10px' }"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, dbInbound">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more" :style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay" @click="a => clickAction(a, dbInbound)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="qrcode" v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard">
<a-icon type="qrcode"></a-icon>
{{ i18n "qrCode" }}
</a-menu-item>
<template v-if="dbInbound.isMultiUser()">
<a-menu-item key="addClient">
<a-icon type="user-add"></a-icon>
{{ i18n "pages.client.add"}}
</a-menu-item>
<a-menu-item key="addBulkClient">
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetInboundClientTraffics"}}
</a-menu-item>
<a-menu-item key="export">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}}
</a-menu-item>
<a-menu-item key="subs" v-if="subSettings.enable">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo">
<a-icon type="info-circle"></a-icon>
{{ i18n "info"}}
</a-menu-item>
</template>
<a-menu-item key="clipboard">
<a-icon type="copy"></a-icon>
{{ i18n "pages.inbounds.exportInbound" }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="retweet"></a-icon> {{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item key="clone">
<a-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}}
</a-menu-item>
<a-menu-item key="delete">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
<a-menu-item v-if="isMobile">
<a-switch size="small" v-model="dbInbound.enable" @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
{{ i18n "pages.inbounds.enable" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="protocol" slot-scope="text, dbInbound">
<a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag>
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag :style="{ margin: '0' }" color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</template>
<template slot="clients" slot-scope="text, dbInbound">
<template v-if="clientCount[dbInbound.id]">
<a-tag :style="{ margin: '0' }" color="green">[[ clientCount[dbInbound.id].clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" v-if="clientCount[dbInbound.id].deactive.length">[[ clientCount[dbInbound.id].deactive.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="red" v-if="clientCount[dbInbound.id].depleted.length">[[ clientCount[dbInbound.id].depleted.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange" v-if="clientCount[dbInbound.id].expiring.length">[[ clientCount[dbInbound.id].expiring.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="blue" v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length ]]</a-tag>
</a-popover>
</template>
</template>
<template slot="deviceLimit" slot-scope="text, dbInbound">
<span v-if="dbInbound.deviceLimit > 0">[[ dbInbound.deviceLimit ]]</span>
<span v-else>{{ i18n "pages.inbounds.unlimited" }}</span>
</template>
<template slot="traffic" slot-scope="text, dbInbound">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr>
<tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td>
</tr>
</table>
</template>
<a-tag :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</template>
</a-tag>
</a-popover>
</template>
<template slot="allTimeInbound" slot-scope="text, dbInbound">
<a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag>
</template>
<template slot="enable" slot-scope="text, dbInbound">
<a-switch v-model="dbInbound.enable" @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
</template>
<template slot="expiryTime" slot-scope="text, dbInbound">
<a-popover v-if="dbInbound.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content" v-if="app.datepicker === 'gregorian'">
[[ DateUtil.formatMillis(dbInbound.expiryTime) ]]
</template>
<template v-else slot="content">
[[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]]
</template>
<a-tag :style="{ minWidth: '50px' }" :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
[[ remainedDays(dbInbound._expiryTime) ]]
</a-tag>
</a-popover>
<a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</a-tag>
</template>
<template slot="info" slot-scope="text, dbInbound">
<a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content">
<table cellpadding="2">
<tr>
<td>{{ i18n "pages.inbounds.protocol" }}</td>
<td>
<a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag>
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag :style="{ margin: '0' }" color="blue">[[ dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" color="green">tls</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality" color="green">reality</a-tag>
</template>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.port" }}</td>
<td><a-tag>[[ dbInbound.port ]]</a-tag></td>
</tr>
<tr v-if="clientCount[dbInbound.id]">
<td>{{ i18n "clients" }}</td>
<td>
<a-tag :style="{ margin: '0' }" color="blue">[[ clientCount[dbInbound.id].clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" v-if="clientCount[dbInbound.id].deactive.length">[[ clientCount[dbInbound.id].deactive.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="red" v-if="clientCount[dbInbound.id].depleted.length">[[ clientCount[dbInbound.id].depleted.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange" v-if="clientCount[dbInbound.id].expiring.length">[[ clientCount[dbInbound.id].expiring.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail" class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="green" v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length ]]</a-tag>
</a-popover>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.traffic" }}</td>
<td>
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr>
<tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td>
</tr>
</table>
</template>
<a-tag :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</template>
</a-tag>
</a-popover>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.expireDate" }}</td>
<td>
<a-tag :style="{ minWidth: '50px', textAlign: 'center' }" v-if="dbInbound.expiryTime > 0"
:color="dbInbound.isExpiry? 'red': 'blue'">
<template v-if="app.datepicker === 'gregorian'">
[[ DateUtil.formatMillis(dbInbound.expiryTime) ]]
</template>
<template v-else>
[[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]]
</template>
</a-tag>
<a-tag v-else :style="{ textAlign: 'center' }" color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</a-tag>
</td>
</tr>
</table>
</template>
<a-badge>
<a-icon v-if="!dbInbound.enable" slot="count" type="pause-circle" :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon>
</a-button>
</a-badge>
</a-popover>
</template>
<template slot="expandedRowRender" slot-scope="record">
<a-table
:row-key="client => client.id"
:columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)"
:pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable"}}
</a-table>
</template>
</a-table>
</a-space>
</a-card>
</a-col>
</a-row>
</transition>
</a-spin>
<a-modal
:title="`{{ i18n "pages.inbounds.oneClick.title" }}`"
:visible="oneClickModalVisible"
:cancel-text="`{{ i18n "cancel" }}`"
:class="themeSwitcher.currentTheme"
@cancel="handleOneClickCancel"
:ok-text="null"
:ok-button-props="{ style: { display: 'none' } }"
>
<a-tabs default-active-key="1">
<a-tab-pane key="1" :tab="`{{ i18n "pages.inbounds.oneClick.generateTab" }}`">
<div v-if="!inboundLink" style="padding: 24px;">
<p>{{ i18n "pages.inbounds.oneClick.prompt" }}</p>
<a-form-item :label="`{{ i18n "pages.inbounds.oneClick.presetType" }}`">
<a-button @click="handleOneClickOk('vless_reality')" block style="margin-bottom: 10px;" html-type="button">
{{ i18n "pages.inbounds.oneClick.preset.vless_reality" }}
</a-button>
<a-button @click="handleOneClickOk('vless_xhttp_reality')" block style="margin-bottom: 10px;" html-type="button">
{{ i18n "pages.inbounds.oneClick.preset.vless_xhttp_reality" }}
</a-button>
<a-button @click="handleOneClickOk('vless_tls_encryption')" block style="margin-bottom: 10px;" html-type="button">
{{ i18n "pages.inbounds.oneClick.preset.vless_tls_encryption" }}
</a-button>
<a-button @click="handleOneClickOk('switch_vision_seed')" disabled block>
{{ i18n "pages.inbounds.oneClick.preset.switch_vision_seed_dev" }}
</a-button>
</a-form-item>
<p><b>{{ i18n "pages.inbounds.oneClick.descriptionTitle" }}</b></p>
<p style="white-space: pre-wrap;">{{ i18n "pages.inbounds.oneClick.descriptionContent" }}</p>
</div>
<div v-else style="padding: 24px;">
<div
@click="copyLink(inboundLink)"
style="text-align: center; margin-bottom: 20px; cursor: pointer; padding: 10px; border-radius: 8px;"
:title="`{{ i18n "pages.inbounds.oneClick.clickToCopyTitle" }}`"
>
<p style="font-weight: bold; font-size: 16px; margin-bottom: 10px;">
{{ i18n "pages.inbounds.oneClick.scanQrcodeTitle" }}
</p>
<canvas id="oneClickQrcodeCanvas"></canvas>
<p style="margin-top: 8px; font-size: 12px; color: #888;">
{{ i18n "pages.inbounds.oneClick.clickToCopyHint" }}
</p>
</div>
<p><b>{{ i18n "pages.inbounds.oneClick.linkCreated" }}</b></p>
<a-input-search
:value="inboundLink"
read-only
:enter-button="`{{ i18n "copy" }}`"
@search="copyLink(inboundLink)"
/>
</div>
</a-spin>
</a-tab-pane>
<a-tab-pane v-if="linkHistory.length > 0" key="2" :tab="`{{ i18n "pages.inbounds.oneClick.historyTab" }}`">
<div style="padding: 24px;">
<a-table
:dataSource="linkHistory"
:pagination="false"
:columns="linkHistoryColumns"
rowKey="time"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'link'">
<a-input-search
:value="record.link"
read-only
:enter-button="`{{ i18n "copy" }}`"
@search="copyLink(record.link)"
/>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}
{{template "component/aPersianDatepicker" .}}
{{template "modals/inboundModal"}}
{{template "modals/promptModal"}}
{{template "modals/qrcodeModal"}}
{{template "modals/textModal"}}
{{template "modals/inboundInfoModal"}}
{{template "modals/clientsModal"}}
{{template "modals/clientsBulkModal"}}
<script>
const columns = [{
title: "ID",
align: 'center',
dataIndex: "id",
width: 40,
responsive: ["xs"],
}, {
title: '{{ i18n "pages.inbounds.operate" }}',
align: 'center',
width: 30,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.inbounds.enable" }}',
align: 'center',
width: 30,
scopedSlots: { customRender: 'enable' },
}, {
title: '{{ i18n "pages.inbounds.remark" }}',
align: 'center',
width: 50,
dataIndex: "remark",
}, {
title: '{{ i18n "pages.inbounds.port" }}',
align: 'center',
dataIndex: "port",
width: 40,
}, {
title: '{{ i18n "pages.inbounds.protocol" }}',
align: 'center',
width: 70,
scopedSlots: { customRender: 'protocol' },
}, {
title: '{{ i18n "clients" }}',
align: 'left',
width: 40,
scopedSlots: { customRender: 'clients' },
}, {
// 【中文注释】: 这是修改后的“设备限制”列定义
title: '{{ i18n "pages.inbounds.deviceLimit" }}',
dataIndex: "deviceLimit",
align: "center",
width: 40,
// 【中文注释】: 将错误的 customRender 改为 scopedSlots
scopedSlots: { customRender: 'deviceLimit' },
}, {
title: '{{ i18n "pages.inbounds.traffic" }}',
align: 'center',
width: 50,
scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.inbounds.expireDate" }}',
align: 'center',
width: 40,
scopedSlots: { customRender: 'expiryTime' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 10,
responsive: ["s"],
}, {
title: '{{ i18n "pages.inbounds.operate" }}',
align: 'center',
width: 25,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.inbounds.remark" }}',
align: 'left',
width: 70,
dataIndex: "remark",
}, {
title: '{{ i18n "pages.inbounds.info" }}',
align: 'center',
width: 10,
scopedSlots: { customRender: 'info' },
}];
const innerColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 65, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "pages.inbounds.enable" }}', width: 35, scopedSlots: { customRender: 'enable' } },
{ title: '{{ i18n "online" }}', width: 35, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'allTime' } },
// 中文注释: 在子表格中新增“限速”列
{
title: '{{ i18n "pages.inbounds.speedLimit" }}',
width: 80,
align: 'center',
dataIndex: 'speedLimit',
customRender: (text) => (text > 0 ? `${text} KB/s` : {{ i18n "pages.inbounds.unlimited" }}),
},
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
];
const innerMobileColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 10, align: 'center', scopedSlots: { customRender: 'actionMenu' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 90, align: 'left', scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.info" }}', width: 10, align: 'center', scopedSlots: { customRender: 'info' } },
];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
persianDatepicker,
loadingStates: {
fetched: false,
spinning: false
},
inbounds: [],
dbInbounds: [],
searchKey: '',
enableFilter: false,
filterBy: '',
searchedInbounds: [],
expireDiff: 0,
trafficDiff: 0,
defaultCert: '',
defaultKey: '',
clientCount: [],
onlineClients: [],
lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
refreshing: false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
subSettings: {
enable : false,
subTitle : '',
subURI : '',
subJsonURI : '',
},
remarkModel: '-ieo',
datepicker: 'gregorian',
tgBotEnable: false,
showAlert: false,
ipLimitEnable: false,
pageSize: 50,
inboundLink: '', // 新增:用来存储生成的可复制链接
// 【中文注释】: 新增数据属性,用于控制“一键配置”模态框的显示状态
oneClickModalVisible: false,
// 【新增】: 用于存储最近生成的链接记录
linkHistory: [],
// 【新增】: 定义链接历史记录表格的列
linkHistoryColumns: [
{
title: `{{ i18n "pages.inbounds.oneClick.historyType" }}`,
dataIndex: 'type',
width: 150,
},
{
title: `{{ i18n "pages.inbounds.oneClick.historyLink" }}`,
dataIndex: 'link',
},
{
title: `{{ i18n "pages.inbounds.oneClick.historyTime" }}`,
dataIndex: 'time',
width: 220,
},
],
// 【中文注释】: 新增数据属性,用于存储“一键配置”模态框中选择的预设值
oneClickPreset: 'vless_reality',
// 【中文注释】: 新增Reality SNI的备选列表用于随机选择
realityDestinations: [
'tesla.com:443',
'sega.com:443',
'apple.com:443',
'icloud.com:443',
'lovelive-anime.jp:443',
'meta.com:443',
],
},
mounted() {
// 【中文注释】:这是唯一且正确的 mounted 钩子。
// 【修改】:将两个 mounted 钩子的逻辑合并到这里。
// 检查非 https 协议,用于安全提示
if (window.location.protocol !== "https:") {
this.showAlert = true;
}
// 初始化面板设置和入站列表
this.getDefaultSettings();
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
} else {
this.getDBInbounds();
}
// 【核心修改】: 从后端加载历史记录,而不是从 localStorage
this.loadHistoryFromServer();
},
methods: {
loading(spinning = true) {
this.loadingStates.spinning = spinning;
},
async getDBInbounds() {
this.refreshing = true;
const msg = await HttpUtil.get('/panel/api/inbounds/list');
if (!msg.success) {
this.refreshing = false;
return;
}
await this.getLastOnlineMap();
await this.getOnlineUsers();
this.setInbounds(msg.obj);
setTimeout(() => {
this.refreshing = false;
}, 500);
},
async getOnlineUsers() {
const msg = await HttpUtil.post('panel/api/inbounds/onlines');
if (!msg.success) {
return;
}
this.onlineClients = msg.obj != null ? msg.obj : [];
},
async getLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (!msg.success || !msg.obj) return;
this.lastOnlineMap = msg.obj || {}
},
async getDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg.success) {
return;
}
with(msg.obj){
this.expireDiff = expireDiff * 86400000;
this.trafficDiff = trafficDiff * 1073741824;
this.defaultCert = defaultCert;
this.defaultKey = defaultKey;
this.tgBotEnable = tgBotEnable;
this.subSettings = {
enable : subEnable,
subTitle : subTitle,
subURI: subURI,
subJsonURI: subJsonURI
};
this.pageSize = pageSize;
this.remarkModel = remarkModel;
this.datepicker = datepicker;
this.ipLimitEnable = ipLimitEnable;
}
},
setInbounds(dbInbounds) {
this.inbounds.splice(0);
this.dbInbounds.splice(0);
this.clientCount.splice(0);
for (const inbound of dbInbounds) {
const dbInbound = new DBInbound(inbound);
to_inbound = dbInbound.toInbound()
this.inbounds.push(to_inbound);
this.dbInbounds.push(dbInbound);
if ([Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(inbound.protocol)) {
if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
continue;
}
this.clientCount[inbound.id] = this.getClientCounts(inbound, to_inbound);
}
}
if (!this.loadingStates.fetched) {
this.loadingStates.fetched = true
}
if(this.enableFilter){
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
},
getClientCounts(dbInbound, inbound) {
let clientCount = 0, active = [], deactive = [], depleted = [], expiring = [], online = [], comments = new Map();
clients = inbound.clients;
clientStats = dbInbound.clientStats
now = new Date().getTime()
if (clients) {
clientCount = clients.length;
if (dbInbound.enable) {
clients.forEach(client => {
if (client.comment) {
comments.set(client.email, client.comment)
}
if (client.enable) {
active.push(client.email);
if (this.isClientOnline(client.email)) online.push(client.email);
} else {
deactive.push(client.email);
}
});
clientStats.forEach(client => {
if (!client.enable) {
depleted.push(client.email);
} else {
if ((client.expiryTime > 0 && (client.expiryTime - now < this.expireDiff)) ||
(client.total > 0 && (client.total - (client.up + client.down) < this.trafficDiff))) expiring.push(client.email);
}
});
} else {
clients.forEach(client => {
deactive.push(client.email);
});
}
}
return {
clients: clientCount,
active: active,
deactive: deactive,
depleted: depleted,
expiring: expiring,
online: online,
comments: comments,
};
},
searchInbounds(key) {
if (ObjectUtil.isEmpty(key)) {
this.searchedInbounds = this.dbInbounds.slice();
} else {
this.searchedInbounds.splice(0, this.searchedInbounds.length);
this.dbInbounds.forEach(inbound => {
if (ObjectUtil.deepSearch(inbound, key)) {
const newInbound = new DBInbound(inbound);
const inboundSettings = JSON.parse(inbound.settings);
if (inboundSettings.hasOwnProperty('clients')) {
const searchedSettings = { "clients": [] };
inboundSettings.clients.forEach(client => {
if (ObjectUtil.deepSearch(client, key)) {
searchedSettings.clients.push(client);
}
});
newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, searchedSettings);
}
this.searchedInbounds.push(newInbound);
}
});
}
},
filterInbounds() {
if (ObjectUtil.isEmpty(this.filterBy)) {
this.searchedInbounds = this.dbInbounds.slice();
} else {
this.searchedInbounds.splice(0, this.searchedInbounds.length);
this.dbInbounds.forEach(inbound => {
const newInbound = new DBInbound(inbound);
const inboundSettings = JSON.parse(inbound.settings);
if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)){
const list = this.clientCount[inbound.id][this.filterBy];
if (list.length > 0) {
const filteredSettings = { "clients": [] };
if (inboundSettings.clients) {
inboundSettings.clients.forEach(client => {
if (list.includes(client.email)) {
filteredSettings.clients.push(client);
}
});
}
newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, filteredSettings);
this.searchedInbounds.push(newInbound);
}
}
});
}
},
toggleFilter(){
if(this.enableFilter) {
this.searchKey = '';
} else {
this.filterBy = '';
this.searchedInbounds = this.dbInbounds.slice();
}
},
// 【新增函数】: 专门用于调用后端 API 放行端口的方法
async openPort(port) {
if (!port || port <= 0) {
this.$message.error('无效端口号,无法放行。');
return false;
}
const loadingMessage = this.$message.loading(`正在向后端发送放行端口 ${port} 的指令...`, 0);
let success = true;
try {
// 〔中文注释〕: 调用后端API它现在会立刻返回一个表示“指令已发送”的JSON。
const msg = await HttpUtil.postForm('/panel/api/server/openPort', { port: port });
if (msg.success) {
// 〔中文注释〕: 将提示信息修改为更准确的“指令已发送”,因为任务正在后台运行。
this.$message.success(`端口 ${port} 放行指令已发送,后台执行中!`);
} else {
success = false;
// 〔中文注释〕: 如果API调用本身就失败了例如后端服务挂了则显示错误。
this.$message.error(`发送端口 ${port} 放行指令失败: ${msg.msg || '未知错误,请检查后端日志。'}`);
}
} catch (err) {
success = false;
console.error(`请求放行端口 ${port} 失败:`, err);
// 〔中文注释〕: 网络错误或返回了非JSON内容例如502网关错误会在这里捕获。
this.$message.error(`请求放行端口 ${port} 失败,请检查网络或后端服务状态。 ${err.message}`);
} finally {
loadingMessage(); // 无论成功或失败,都关闭加载提示
}
return success;
},
// 〔中文注释〕: 新增一个专门用于生成“一键配置”二维码的方法
generateOneClickQrCode(link) {
// 〔中文注释〕: this.$nextTick 是一个 Vue 特有的功能,它会等待 DOM 更新完成后再执行内部的代码。
// 这是必需的,因为 <canvas> 元素只有在 inboundLink 被赋值后才会显示出来,我们需要确保它存在才能进行绘制。
this.$nextTick(() => {
const canvasElement = document.getElementById('oneClickQrcodeCanvas');
if (canvasElement) {
new QRious({
element: canvasElement, // 〔中文注释〕: 关联到我们刚刚在 HTML 中添加的 canvas 元素
value: link, // 〔中文注释〕: 二维码的内容就是生成的分享链接
size: 200, // 〔中文注释〕: 设置二维码的尺寸为 200x200 像素
level: 'M' // 〔中文注释〕: 设置较高的容错率
});
}
});
},
// 【修改后】: 按照固定长度 2,4,6,8,10,12,14,16 来生成 shortIds
generateShortIds() {
const chars = "0123456789abcdef"; // 只使用十六进制字符
const lengths = [2, 4, 6, 8, 10, 12, 14, 16];
const shortIds = [];
for (const len of lengths) {
let shortId = "";
for (let j = 0; j < len; j++) {
shortId += chars[Math.floor(Math.random() * chars.length)];
}
shortIds.push(shortId);
}
return shortIds;
},
// 【中文注释】: 添加这个新函数
async loadHistoryFromServer() {
const msg = await HttpUtil.get('/panel/api/server/history/load');
if (msg.success && Array.isArray(msg.obj)) {
// 后端返回的是标准对象,我们需要转换成前端需要的格式
this.linkHistory = msg.obj.map(item => ({
type: item.Type,
link: item.Link,
time: new Date(item.CreatedAt).toLocaleString()
}));
}
},
// 【中文注释】: 保存链接到历史记录,现在是调用后端 API
async saveLinkToHistory(type, link) {
// 1. 在前端立即更新UI提供即时反馈
const newRecord = { type: type, link: link, time: new Date().toLocaleString() };
this.linkHistory.unshift(newRecord); // 添加到数组开头
if (this.linkHistory.length > 10) {
this.linkHistory.pop(); // 保持最多10条记录
}
// 2. 发送数据到后端进行持久化保存,发送表单格式,而不是 JSON
await HttpUtil.postForm('/panel/api/server/history/save', { type: type, link: link });
},
// 【中文注释】: 以下是新增和修改后的方法,用于处理“一键配置”和“订阅转换”
// 【中文注释】: 打开“一键配置”模态框
openOneClickConfig() {
this.oneClickModalVisible = true;
},
// 【中文注释】: 处理“一键配置”模态框的确认操作
async handleOneClickOk(presetType) {
this.loadingStates.spinning = true;
// 【中文注释】: 根据选择的预设值调用不同的创建函数
if (presetType === 'vless_reality') {
await this.createVlessRealityInbound();
} else if (presetType === 'vless_tls_encryption') {
await this.createVlessTlsEncryptionInbound();
} else if (presetType === 'vless_xhttp_reality') { // 【新增】: 响应新按钮的点击
await this.createVlessXhttpRealityInbound();
} else if (presetType === 'switch') {
this.$message.info('Switch 模式配置尚未实现');
} else {
this.$message.error('未知的预设配置!');
}
// 【中文注释】: 无论如何,最后都关闭加载状态
this.loadingStates.spinning = false;
},
// 【中文注释】: 处理“一键配置”模态框的取消操作
handleOneClickCancel() {
this.oneClickModalVisible = false;
this.inboundLink = ''; // 关闭时清空链接,以便下次重新打开时显示选项
},
// 【中文注释】: 创建 VLESS + Reality 入站的具体实现
async createVlessRealityInbound() {
try {
// 【中文注释】: 使用 Promise.all 并行请求后端的两个接口以获取密钥对和UUID
// 【重要】: 这里调用的都是项目中已经存在的后端接口
const [keyPairMsg, uuidMsg] = await Promise.all([
HttpUtil.get('/panel/api/server/getNewX25519Cert'),
HttpUtil.get('/panel/api/server/getNewUUID')
]);
if (!keyPairMsg.success || !keyPairMsg.obj || !uuidMsg.success || !uuidMsg.obj) {
this.$message.error('无法从服务器获取 Reality 密钥对或 UUID请检查后端服务');
return;
}
const { privateKey, publicKey } = keyPairMsg.obj;
const { uuid } = uuidMsg.obj;
const remark = Array.from({length:8}, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
[Math.floor(Math.random() * 62)]
).join("");
// 随机生成 10000-55535 之间的端口
const port = Math.floor(Math.random() * (55535 - 10000 + 1)) + 10000;
// 【新增代码】: 自动调用 openPort 接口放行端口
const portOpenSuccess = await this.openPort(port);
if (!portOpenSuccess) {
// 【不中断流程,给出警告提示】
this.$message.warning(`**注意:** 自动放行端口 ${port} 失败,请务必手动到您的 **VPS 防火墙**(例如 ufw、security group放行该端口否则入站将无法连接。`);
}
// 【中文注释】: 从预设列表中随机选择一个SNI
const randomDest = this.realityDestinations[Math.floor(Math.random() * this.realityDestinations.length)];
// 从 randomDest (例如 'microsoft.com:443') 中提取出域名 ('microsoft.com')
const randomSni = randomDest.split(':')[0];
// 【随机生成】: 调用新的函数 generateShortIds()
const shortIds = this.generateShortIds();
const linkSid = shortIds[Math.floor(Math.random() * shortIds.length)];
const data = {
up: 0,
down: 0,
total: 0,
remark: remark,
enable: true,
expiryTime: 0,
deviceLimit: 0,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid, // 【中文注释】: 使用后端生成的 UUID
flow: "xtls-rprx-vision",
email: remark,
level: 0,
enable: true
}],
decryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
realitySettings: {
show: false,
target: randomDest,
xver: 0,
serverNames: [ randomSni, `www.${randomSni}` ],
settings: {
publicKey: publicKey,
spiderX: "/",
mldsa65Verify: ""
},
privateKey: privateKey,
maxClientVer: "",
minClientVer: "",
maxTimediff: 0,
mldsa65Seed: "", // 【中文注释】: 一般情况下【留空】
"shortIds": shortIds
},
tcpSettings: {
acceptProxyProtocol: false,
header: {
type: "none"
}
}
}),
// 【中文注释】:构建完整的 sniffing 对象
sniffing: JSON.stringify({
enabled: true,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
}),
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg.success) {
const link = `vless://${uuid}@${window.location.hostname}:${port}?type=tcp&encryption=none&security=reality&pbk=${publicKey}&fp=chrome&sni=${randomSni}&sid=${linkSid}&spx=%2F&flow=xtls-rprx-vision#${remark}-${remark}`;
this.inboundLink = link;
// 〔中文注释〕: 在这里调用新增的二维码生成方法,把链接传进去
this.generateOneClickQrCode(link);
// 【新增】: 调用保存方法,将链接保存到历史记录
this.saveLinkToHistory('vless_reality', link);
this.$message.success('一键配置入站创建成功!');
await this.getDBInbounds();
} else {
// 如果后端返回失败,显示后端的消息
this.$message.error('创建失败: ' + msg.msg);
}
} catch (err) {
console.error("创建一键配置入站时出错:", err);
this.$message.error('创建过程中发生网络错误,请检查后端日志。');
}
},
// 【方法】: 创建 VLESS + TLS + Encryption 的入站配置
async createVlessTlsEncryptionInbound() {
try {
const [encMsg, uuidMsg] = await Promise.all([
HttpUtil.get('/panel/api/server/getNewVlessEnc'),
HttpUtil.get('/panel/api/server/getNewUUID')
]);
// 【修正】: 增加对 API 返回数据的严格检查
if (!encMsg.success || !encMsg.obj || !Array.isArray(encMsg.obj.auths) || encMsg.obj.auths.length === 0 || !uuidMsg.success || !uuidMsg.obj) {
this.$message.error('无法从服务器获取 Vless 加密配置或 UUID请检查后端服务');
return;
}
// 【修正】: 遍历找到正确的 ML-KEM-768 密钥
const mlkemAuth = encMsg.obj.auths.find(auth => auth.label === "ML-KEM-768, Post-Quantum");
if (!mlkemAuth || !mlkemAuth.decryption || !mlkemAuth.encryption) {
this.$message.error('未能从服务器获取到 ML-KEM-768 加密密钥,请检查 Xray 版本!');
return;
}
// 【修正】: 正确从嵌套数据中解构出 decryption 和 encryption
const { decryption, encryption } = mlkemAuth;
const { uuid } = uuidMsg.obj;
const remark = Array.from({length:8}, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
[Math.floor(Math.random() * 62)]
).join("");
// 随机生成2053, 2083, 2087, 2096, 8443这5个端口中的其中一个
const allowedPorts = [2053, 2083, 2087, 2096, 8443];
const port = allowedPorts[Math.floor(Math.random() * allowedPorts.length)];
// 【新增代码】: 自动调用 openPort 接口放行端口
const portOpenSuccess = await this.openPort(port);
if (!portOpenSuccess) {
// 【不中断流程,给出警告提示】
this.$message.warning(`**注意:** 自动放行端口 ${port} 失败,请务必手动到您的 **VPS 防火墙**(例如 ufw、security group放行该端口否则入站将无法连接。`);
}
// 随机生成一个8位大小写字母的路径
const path = "/" + Array.from({ length: 8 }, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 52)]
).join("");
// 固定使用面板域名作为 SNI
const serverName = window.location.hostname;
// 【必须】: 动态设置证书路径
const certPath = `/root/cert/${window.location.hostname}/fullchain.pem`;
const keyPath = `/root/cert/${window.location.hostname}/privkey.pem`;
const data = {
up: 0,
down: 0,
total: 0,
remark: remark,
enable: true,
expiryTime: 0,
deviceLimit: 0,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid, // 【中文注释】: 使用后端生成的 UUID
flow: "",
email: remark,
level: 0,
password: "",
enable: true
}],
decryption: decryption, // 使用从 API 获取的 decryption
encryption: encryption, // 【重要】: 加上 encryption 参数
selectedAuth: "ML-KEM-768, Post-Quantum"
}),
streamSettings: JSON.stringify({
network: "xhttp", // 使用 XHTTP 传输
security: "tls",
tlsSettings: {
alpn: ["h2", "http/1.1"], // 优先 h2再 http/1.1
certificates: [{
buildChain: false,
certificateFile: certPath,
keyFile: keyPath,
oneTimeLoading: false,
usage: "encipherment"
}],
cipherSuites: "",
disableSystemRoot: false,
echForceQuery: "none",
echServerKeys: "",
enableSessionResumption: false,
maxVersion: "1.3",
minVersion: "1.2",
rejectUnknownSni: false,
serverName: serverName, // 使用面板域名的 SNI
verifyPeerCertInNames: ["dns.google", "cloudflare-dns.com"]
},
xhttpSettings: {
headers: {}, // 可按需填充
host: "", // 一般默认保持空
mode: "packet-up", // packet-up 分包上行
noSSEHeader: false,
path: path, // 随机 8 位大小写字母路径
scMaxBufferedPosts: 30,
scMaxEachPostBytes: "1000000",
scStreamUpServerSecs: "20-80",
xPaddingBytes: "100-1000"
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
}),
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg.success) {
// 【重要】: 分享链接格式,添加 encryption 和 flow
const link = `vless://${uuid}@${window.location.hostname}:${port}?type=tcp&encryption=${encryption}&security=tls&fp=chrome&alpn=http%2F1.1&sni=${serverName}&flow=xtls-rprx-vision#${remark}-${remark}`;
this.inboundLink = link;
// 〔中文注释〕: 在这里调用新增的二维码生成方法,把链接传进去
this.generateOneClickQrCode(link);
// 【新增】: 调用保存方法,将链接保存到历史记录
this.saveLinkToHistory('vless_tls_encryption', link);
this.$message.success('VLESS + TLS + Encryption 配置创建成功!');
await this.getDBInbounds();
} else {
this.$message.error('创建失败: ' + msg.msg);
}
} catch (err) {
console.error("创建 VLESS + TLS + Encryption 入站时出错:", err);
this.$message.error('创建过程中发生网络错误,请检查后端日志。');
}
},
// 【新增方法】: 创建 VLESS + XHTTP + Reality 入站
async createVlessXhttpRealityInbound() {
try {
// 【中文注释】: 并行请求 Reality 密钥对和 UUID
const [keyPairMsg, uuidMsg] = await Promise.all([
HttpUtil.get('/panel/api/server/getNewX25519Cert'),
HttpUtil.get('/panel/api/server/getNewUUID')
]);
if (!keyPairMsg.success || !keyPairMsg.obj || !uuidMsg.success || !uuidMsg.obj) {
this.$message.error('无法从服务器获取 Reality 密钥对或 UUID请检查后端服务');
return;
}
const { privateKey, publicKey } = keyPairMsg.obj;
const { uuid } = uuidMsg.obj;
// 【中文注释】: 生成随机备注、端口和路径
const remark = Array.from({length: 8}, () => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(Math.random() * 62)]).join("");
const port = Math.floor(Math.random() * (55535 - 10000 + 1)) + 10000;
const path = "/" + Array.from({ length: 8 }, () => "abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 26)]).join("");
// 【中文注释】: 尝试自动放行端口
const portOpenSuccess = await this.openPort(port);
if (!portOpenSuccess) {
this.$message.warning(`**注意:** 自动放行端口 ${port} 失败,请务必手动到您的 **VPS 防火墙**放行该端口,否则入站将无法连接。`);
}
// 【中文注释】: 随机选择 SNI 和 Short ID
const randomDest = this.realityDestinations[Math.floor(Math.random() * this.realityDestinations.length)];
const randomSni = randomDest.split(':')[0];
const shortIds = this.generateShortIds();
const linkSid = shortIds[Math.floor(Math.random() * shortIds.length)];
// 【中文注释】: 构建发送给后端的入站数据,结构严格按照你提供的 JSON
const data = {
up: 0,
down: 0,
total: 0,
remark: remark,
enable: true,
expiryTime: 0,
deviceLimit: 0,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid,
flow: "", // 【中文注释】: XHTTP 模式下 flow 为空
email: remark,
level: 0,
password: "",
enable: true
}],
decryption: "none",
selectedAuth: "X25519, not Post-Quantum"
}),
streamSettings: JSON.stringify({
network: "xhttp", // 【中文注释】: 网络模式为 xhttp
security: "reality",
realitySettings: {
show: false,
target: randomDest,
xver: 0,
serverNames: [randomSni, `www.${randomSni}`],
privateKey: privateKey,
maxClientVer: "",
minClientVer: "",
maxTimediff: 0,
mldsa65Seed: "",
shortIds: shortIds,
// 【中文注释】: 请注意 publicKey 现在嵌套在 'settings' 对象中
settings: {
publicKey: publicKey,
spiderX: "/",
mldsa65Verify: ""
}
},
xhttpSettings: {
headers: {},
host: "",
mode: "stream-up",
noSSEHeader: false,
path: path, // 【中文注释】: 使用上面生成的随机路径
scMaxBufferedPosts: 30,
scMaxEachPostBytes: "1000000",
scStreamUpServerSecs: "20-80",
xPaddingBytes: "100-1000"
}
}),
sniffing: JSON.stringify({
enabled: true,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
}),
};
// 【中文注释】: 发送创建请求
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg.success) {
// 【中文注释】: 创建成功后,生成正确的分享链接,注意包含 type=xhttp 和 path 参数
const link = `vless://${uuid}@${window.location.hostname}:${port}?type=xhttp&encryption=none&path=${encodeURIComponent(path)}&host=&mode=stream-up&security=reality&pbk=${publicKey}&fp=chrome&sni=${randomSni}&sid=${linkSid}&spx=%2F#${remark}-${remark}`;
this.inboundLink = link;
// 【中文注释】: 显示二维码并保存到历史记录
this.generateOneClickQrCode(link);
this.saveLinkToHistory('vless_xhttp_reality', link); // 【中文注释】: 使用唯一的类型名保存
this.$message.success('VLESS + XHTTP + Reality 配置创建成功!');
await this.getDBInbounds();
} else {
this.$message.error('创建失败: ' + msg.msg);
}
} catch (err) {
console.error("创建 VLESS + XHTTP + Reality 入站时出错:", err);
this.$message.error('创建过程中发生网络错误,请检查后端日志。');
}
},
// 【中文注释】: 这是修复后的 copyLink 方法
async copyLink(linkToCopy) {
const link = linkToCopy || this.inboundLink;
if (!link) {
this.$message.warning('没有可复制的链接。');
return;
}
// 【中文注释】: 优先使用 Clipboard API但它需要安全上下文 (HTTPS)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(link);
this.$message.success('链接已成功复制到剪贴板!');
return;
} catch (err) {
console.error('Clipboard API 复制失败,将尝试回退方法:', err);
// 如果失败,则继续执行下面的回退方法
}
}
// 【中文注释】: 回退方案,使用 document.execCommand兼容性更好
const textArea = document.createElement("textarea");
textArea.value = link;
// 将输入框移动到屏幕外,防止干扰
textArea.style.position = "absolute";
textArea.style.left = "-9999px";
textArea.setAttribute("readonly", "");
document.body.appendChild(textArea);
// 选择输入框中的内容
textArea.select();
try {
// 执行复制命令
document.execCommand('copy');
this.$message.success('链接已成功复制到剪贴板!');
} catch (err) {
console.error('execCommand 复制失败:', err);
this.$message.error('复制失败,请手动复制。');
} finally {
// 无论成功与否,最后都移除这个临时输入框
document.body.removeChild(textArea);
}
},
// 〔中文注释〕: 处理“订阅转换”按钮的点击事件 (已更新为完整逻辑)
handleSubscriptionConversion() {
// 【第一步】: 检查当前协议是否为 HTTPS
if (window.location.protocol !== "https:") {
this.$warning({
title: '必须使用 HTTPS 访问',
content: '【订阅转换】功能需要一个安全的 HTTPS 环境才能运行。请先为您的面板域名正确配置 SSL 证书,然后再使用此功能。',
class: themeSwitcher.currentTheme,
});
return; // 如果是 http则终止后续所有操作
}
// 【第二步】:弹出确认框,询问用户是否继续
this.$confirm({
title: '{{ i18n "pages.inbounds.subConversion.modalTitle"}}',
content: '{{ i18n "pages.inbounds.subConversion.modalContent"}}',
okText: '{{ i18n "pages.inbounds.subConversion.modalOk"}}',
cancelText: '{{ i18n "cancel" }}',
class: themeSwitcher.currentTheme,
onOk: () => {
const subConverterUrl = `https://${window.location.hostname}:15268`;
const loadingMessage = this.$message.loading('正在检测订阅转换服务 (运行端口15268)...', 0);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时检测
// 【第三步】: 尝试通过 fetch API 访问订阅转换服务端口
fetch(subConverterUrl, {
method: 'GET',
mode: 'no-cors', // 〔中文注释〕: 使用 no-cors 模式进行简单的网络连通性检查
signal: controller.signal
})
.then(response => {
// 【情况 A】: 访问成功,服务正在运行
clearTimeout(timeoutId);
loadingMessage(); // 关闭加载提示
this.$message.success('服务检测成功,正在为您跳转...', 2);
window.open(subConverterUrl, '_blank'); // 在新标签页中打开
})
.catch(err => {
// 【情况 B】: 访问失败 (例如超时或连接被拒绝)
clearTimeout(timeoutId);
loadingMessage(); // 关闭加载提示
// 〔中文注释〕: 弹出新的确认框,提供“一键安装”选项
this.$confirm({
title: '{{ i18n "pages.inbounds.subConversion.notFoundTitle"}}',
content: `无法连接到订阅转换服务。这可能意味着它尚未安装或未在运行。您想现在远程执行安装指令吗?
【重要提示】: 执行前,请确保您服务器的防火墙已放行 8000 和 15268 这两个端口。`,
okText: '立即安装',
cancelText: '稍后再说',
class: themeSwitcher.currentTheme,
onOk: async () => {
// 〔中文注释〕:从这里开始是全新的轮询检测逻辑
// 确保 loginUrl 被定义,用于成功提示
const subConverterUrl = `https://${window.location.hostname}:15268`;
const loginUrl = subConverterUrl;
const portsToOpen = [8000, 15268];
let openPortFailed = false; // 用于标记是否有端口放行失败
// 【新增逻辑】:先尝试放行 8000 和 15268 端口 (不中断流程)
const openPortLoading = this.$message.loading(`正在尝试自动放行端口 ${portsToOpen.join(' 和 ')}...`, 0);
// 使用 Promise.allSettled 确保所有端口放行尝试完成后才继续
const openPortResults = await Promise.allSettled(
portsToOpen.map(port => this.openPort(port))
);
openPortLoading(); // 关闭放行加载提示
openPortResults.forEach(result => {
// 检查每个放行操作是否失败 (openPort 返回 false)
if (result.status === 'fulfilled' && result.value === false) {
openPortFailed = true;
}
// Promise.allSettled 的 rejected 状态表明 openPort 内部抛出异常 (如网络错误),也视为放行失败
if (result.status === 'rejected') {
openPortFailed = true;
}
});
if (openPortFailed) {
// 使用非模态、自动关闭的 this.$message.warning
this.$message.warning({
content: `端口自动放行失败。**安装流程将继续**,但请您务必进入 VPS 手动放行 8000 和 15268 端口。`,
duration: 10, // 持续 10 秒后自动关闭
key: 'portOpenWarning', // 可选指定一个key防止重复弹出
});
}
// 1. 先向后端发送安装指令
const installLoading = this.$message.loading('正在向后端发送安装指令...', 0);
try {
const msg = await HttpUtil.post('/panel/api/server/install/subconverter');
installLoading(); // 指令发送成功后,关闭这个短暂的加载提示
if (!msg.success) {
this.$error({
title: '指令发送失败',
content: msg.msg || '发生未知错误,请检查后端日志。',
class: themeSwitcher.currentTheme,
});
return;
}
} catch (error) {
installLoading();
this.$error({
title: '前端请求失败',
content: '无法连接到后端API接口请检查您的网络连接或后端服务是否正常运行。',
class: themeSwitcher.currentTheme,
});
return;
}
// 2. 指令发送成功后,创建并显示一个用于等待服务启动的模态框
const h = this.$createElement;
const modal = this.$info({
title: '⏳ 正在安装并启动服务...',
content: '安装指令已发送后台正在执行安装和启动。请耐心等待预计40秒内自动给出安装成功提示。',
class: themeSwitcher.currentTheme,
okText: '后台运行',
okButtonProps: {
props: {
loading: true // 让按钮一直处于加载状态
}
},
});
// 3. 核心妥协:使用固定延时代替不可靠的前端轮询
const SERVICE_STARTUP_WAIT_TIME = 40000; // 等待 40 秒钟
setTimeout(() => {
// 延时结束后,强制更新模态框为成功状态
modal.update({
title: '✅ 安装成功!',
// 使用 VNode 来创建指定的提示内容
content: h('div', { style: 'white-space: pre-wrap; text-align: left;' }, [
h('p', { style: 'font-weight: bold; font-size: 16px; margin-bottom: 10px;'}, '🎉 恭喜!【订阅转换】模块已成功安装!'),
h('p', '您现在可以使用以下地址访问 Web 界面:'),
h('p', [
h('strong', '🔗 登录地址: '),
h('a', {
attrs: {
href: loginUrl,
target: '_blank'
},
style: 'font-family: monospace; font-weight: bold;'
}, loginUrl)
]),
// 突出显示用户名和密码
h('p', [ '默认用户名: ', h('strong', 'admin') ]),
h('p', [ '默认 密码: ', h('strong', '123456') ]),
h('p', { style: 'font-size: 12px; color: #888; margin-top: 10px;'}, '可登录订阅转换后台修改您的密码!'),
h('p', { style: 'margin-top: 10px; font-style: italic; color: #ff4d4f;' }, '---------------------------------------'), // 使用红色分隔线
h('p', { style: 'font-style: italic; color: #ff4d4f;' }, '【重要提示】:若您无法访问上述地址,请进入 VPS 中手动放行 8000 和 15268 端口。') // 红色字体
]),
okText: '完成',
okButtonProps: {
props: {
loading: false
}
},
});
}, SERVICE_STARTUP_WAIT_TIME);
},
});
});
},
});
},
generalActions(action) {
switch (action.key) {
case "import":
this.importInbound();
break;
case "export":
this.exportAllLinks();
break;
case "subs":
this.exportAllSubs();
break;
case "resetInbounds":
this.resetAllTraffic();
break;
case "resetClients":
this.resetAllClientTraffics(-1);
break;
case "delDepletedClients":
this.delDepletedClients(-1)
break;
}
},
clickAction(action, dbInbound) {
switch (action.key) {
case "qrcode":
this.showQrcode(dbInbound.id);
break;
case "showInfo":
this.showInfo(dbInbound.id);
break;
case "edit":
this.openEditInbound(dbInbound.id);
break;
case "addClient":
this.openAddClient(dbInbound.id)
break;
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
case "export":
this.inboundLinks(dbInbound.id);
break;
case "subs":
this.exportSubs(dbInbound.id);
break;
case "clipboard":
this.copy(dbInbound.id);
break;
case "resetTraffic":
this.resetTraffic(dbInbound.id);
break;
case "resetClients":
this.resetAllClientTraffics(dbInbound.id);
break;
case "clone":
this.openCloneInbound(dbInbound);
break;
case "delete":
this.delInbound(dbInbound.id);
break;
case "delDepletedClients":
this.delDepletedClients(dbInbound.id)
break;
}
},
openCloneInbound(dbInbound) {
this.$confirm({
title: '{{ i18n "pages.inbounds.cloneInbound"}} \"' + dbInbound.remark + '\"',
content: '{{ i18n "pages.inbounds.cloneInboundContent"}}',
okText: '{{ i18n "pages.inbounds.cloneInboundOk"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel" }}',
onOk: () => {
const baseInbound = dbInbound.toInbound();
dbInbound.up = 0;
dbInbound.down = 0;
this.cloneInbound(baseInbound, dbInbound);
},
});
},
async cloneInbound(baseInbound, dbInbound) {
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark + " - Cloned",
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
listen: '',
port: RandomUtil.randomInteger(10000, 60000),
protocol: baseInbound.protocol,
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
streamSettings: baseInbound.stream.toString(),
sniffing: baseInbound.sniffing.toString(),
};
await this.submit('/panel/api/inbounds/add', data, inModal);
},
openAddInbound() {
inModal.show({
title: '{{ i18n "pages.inbounds.addInbound"}}',
okText: '{{ i18n "create"}}',
cancelText: '{{ i18n "close" }}',
confirm: async (inbound, dbInbound) => {
await this.addInbound(inbound, dbInbound, inModal);
},
isEdit: false
});
},
openEditInbound(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
const inbound = dbInbound.toInbound();
inModal.show({
title: '{{ i18n "pages.inbounds.modifyInbound"}}',
okText: '{{ i18n "update"}}',
cancelText: '{{ i18n "close" }}',
inbound: inbound,
dbInbound: dbInbound,
confirm: async (inbound, dbInbound) => {
await this.updateInbound(inbound, dbInbound);
},
isEdit: true
});
},
async addInbound(inbound, dbInbound) {
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
// 新增这一行
deviceLimit: dbInbound.deviceLimit,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()){
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
}
data.sniffing = inbound.sniffing.toString();
await this.submit('/panel/api/inbounds/add', data, inModal);
},
async updateInbound(inbound, dbInbound) {
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
// 新增这一行
deviceLimit: dbInbound.deviceLimit,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()){
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
}
data.sniffing = inbound.sniffing.toString();
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
},
openAddClient(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientModal.show({
title: '{{ i18n "pages.client.add"}}',
okText: '{{ i18n "pages.client.submitAdd"}}',
dbInbound: dbInbound,
confirm: async (clients, dbInboundId) => {
await this.addClient(clients, dbInboundId, clientModal);
},
isEdit: false
});
},
openAddBulkClient(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientsBulkModal.show({
title: '{{ i18n "pages.client.bulk"}} ' + dbInbound.remark,
okText: '{{ i18n "pages.client.bulk"}}',
dbInbound: dbInbound,
confirm: async (clients, dbInboundId) => {
await this.addClient(clients, dbInboundId, clientsBulkModal);
},
});
},
openEditClient(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clients = this.getInboundClients(dbInbound);
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
clientModal.show({
title: '{{ i18n "pages.client.edit"}}',
okText: '{{ i18n "pages.client.submitEdit"}}',
dbInbound: dbInbound,
index: index,
confirm: async (client, dbInboundId, clientId) => {
clientModal.loading();
await this.updateClient(client, dbInboundId, clientId);
clientModal.close();
},
isEdit: true
});
},
findIndexOfClient(protocol, clients, client) {
switch (protocol) {
case Protocols.TROJAN:
case Protocols.SHADOWSOCKS:
return clients.findIndex(item => item.password === client.password && item.email === client.email);
default: return clients.findIndex(item => item.id === client.id && item.email === client.email);
}
},
async addClient(clients, dbInboundId, modal) {
const data = {
id: dbInboundId,
settings: '{"clients": [' + clients.toString() + ']}',
};
await this.submit(`/panel/api/inbounds/addClient`, data, modal);
},
async updateClient(client, dbInboundId, clientId) {
const data = {
id: dbInboundId,
settings: '{"clients": [' + client.toString() + ']}',
};
await this.submit(`/panel/api/inbounds/updateClient/${clientId}`, data, clientModal);
},
resetTraffic(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' #' + dbInboundId,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => {
const inbound = dbInbound.toInbound();
dbInbound.up = 0;
dbInbound.down = 0;
this.updateInbound(inbound, dbInbound);
},
});
},
delInbound(dbInboundId) {
this.$confirm({
title: '{{ i18n "pages.inbounds.deleteInbound"}}' + ' #' + dbInboundId,
content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId),
});
},
delClient(dbInboundId, client,confirmation = true) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientId = this.getClientId(dbInbound.protocol, client);
if (confirmation){
this.$confirm({
title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`),
});
} else {
this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`);
}
},
getSubGroupClients(dbInbounds, currentClient) {
const response = {
inbounds: [],
clients: [],
editIds: []
}
if (dbInbounds && dbInbounds.length > 0 && currentClient) {
dbInbounds.forEach((dbInboundItem) => {
const dbInbound = new DBInbound(dbInboundItem);
if (dbInbound) {
const inbound = dbInbound.toInbound();
if (inbound) {
const clients = inbound.clients;
if (clients.length > 0) {
clients.forEach((client) => {
if (client['subId'] === currentClient['subId']) {
client['inboundId'] = dbInboundItem.id
client['clientId'] = this.getClientId(dbInbound.protocol, client)
response.inbounds.push(dbInboundItem.id)
response.clients.push(client)
response.editIds.push(client['clientId'])
}
})
}
}
}
})
}
return response;
},
getClientId(protocol, client) {
switch (protocol) {
case Protocols.TROJAN: return client.password;
case Protocols.SHADOWSOCKS: return client.email;
default: return client.id;
}
},
checkFallback(dbInbound) {
newDbInbound = new DBInbound(dbInbound);
if (dbInbound.listen.startsWith("@")){
rootInbound = this.inbounds.find((i) =>
i.isTcp &&
['trojan','vless'].includes(i.protocol) &&
i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
);
if (rootInbound) {
newDbInbound.listen = rootInbound.listen;
newDbInbound.port = rootInbound.port;
newInbound = newDbInbound.toInbound();
newInbound.stream.security = rootInbound.stream.security;
newInbound.stream.tls = rootInbound.stream.tls;
newInbound.stream.externalProxy = rootInbound.stream.externalProxy;
newDbInbound.streamSettings = newInbound.stream.toString();
}
}
return newDbInbound;
},
showQrcode(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
newDbInbound = this.checkFallback(dbInbound);
qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client);
},
showInfo(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
index=0;
if (dbInbound.isMultiUser()){
inbound = dbInbound.toInbound();
clients = inbound.clients;
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
}
newDbInbound = this.checkFallback(dbInbound);
infoModal.show(newDbInbound, index);
},
switchEnable(dbInboundId,state) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
dbInbound.enable = state;
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
},
async switchEnableClient(dbInboundId, client) {
this.loading()
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
inbound = dbInbound.toInbound();
clients = inbound.clients;
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
clients[index].enable = !clients[index].enable;
clientId = this.getClientId(dbInbound.protocol, clients[index]);
await this.updateClient(clients[index], dbInboundId, clientId);
this.loading(false);
},
async submit(url, data, modal) {
const msg = await HttpUtil.postWithModal(url, data, modal);
if (msg.success) {
await this.getDBInbounds();
}
},
getInboundClients(dbInbound) {
return dbInbound.toInbound().clients;
},
resetClientTraffic(client, dbInboundId, confirmation = true) {
if (confirmation){
this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email),
})
} else {
this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email);
}
},
resetAllTraffic() {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetAllTrafficTitle"}}',
content: '{{ i18n "pages.inbounds.resetAllTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/resetAllTraffics'),
});
},
resetAllClientTraffics(dbInboundId) {
this.$confirm({
title: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficTitle"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
content: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficContent"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/resetAllClientTraffics/' + dbInboundId),
})
},
delDepletedClients(dbInboundId) {
this.$confirm({
title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/delDepletedClients/' + dbInboundId),
})
},
isExpiry(dbInbound, index) {
return dbInbound.toInbound().isExpiry(index);
},
getUpStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
return clientStats ? clientStats.up : 0;
},
getDownStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
return clientStats ? clientStats.down : 0;
},
getSumStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
return clientStats ? clientStats.up + clientStats.down : 0;
},
getAllTimeClient(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return 0;
return clientStats.allTime || (clientStats.up + clientStats.down);
},
getRemStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return 0;
remained = clientStats.total - (clientStats.up + clientStats.down);
return remained>0 ? remained : 0;
},
clientStatsColor(dbInbound, email) {
if (email.length == 0) return ColorUtils.clientUsageColor();
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
},
statsProgress(dbInbound, email) {
if (email.length == 0) return 100;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return 0;
if (clientStats.total == 0) return 100;
return 100*(clientStats.down + clientStats.up)/clientStats.total;
},
expireProgress(expTime, reset) {
now = new Date().getTime();
remainedSeconds = expTime < 0 ? -expTime/1000 : (expTime-now)/1000;
resetSeconds = reset * 86400;
if (remainedSeconds >= resetSeconds) return 0;
return 100*(1-(remainedSeconds/resetSeconds));
},
remainedDays(expTime){
if (expTime == 0) return null;
if (expTime < 0) return TimeFormatter.formatSecond(expTime/-1000);
now = new Date().getTime();
if (expTime < now) return '{{ i18n "depleted" }}';
return TimeFormatter.formatSecond((expTime-now)/1000);
},
statsExpColor(dbInbound, email){
if (email.length == 0) return '#7a316f';
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return '#7a316f';
statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
switch (true) {
case statsColor == "red" || expColor == "red":
return "#cf3c3c"; // Red
case statsColor == "orange" || expColor == "orange":
return "#f37b24"; // Orange
case statsColor == "green" || expColor == "green":
return "#008771"; // Green
default:
return "#7a316f"; // purple
}
},
isClientEnabled(dbInbound, email) {
clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
return clientStats ? clientStats['enable'] : true;
},
isClientOnline(email) {
return this.onlineClients.includes(email);
},
getLastOnline(email) {
return this.lastOnlineMap[email] || null
},
formatLastOnline(email) {
const ts = this.getLastOnline(email)
if (!ts) return '-'
if (this.datepicker === 'gregorian') {
return DateUtil.formatMillis(ts)
}
return DateUtil.convertToJalalian(moment(ts))
},
isRemovable(dbInboundId) {
return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
},
inboundLinks(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
newDbInbound = this.checkFallback(dbInbound);
txtModal.show('{{ i18n "pages.inbounds.export"}}', newDbInbound.genInboundLinks(this.remarkModel), newDbInbound.remark);
},
exportSubs(dbInboundId) {
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
const clients = this.getInboundClients(dbInbound);
let subLinks = []
if (clients != null){
clients.forEach(c => {
if (c.subId && c.subId.length>0){
subLinks.push(this.subSettings.subURI + c.subId)
}
})
}
txtModal.show(
'{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
[...new Set(subLinks)].join('\n'),
dbInbound.remark + "-Subs");
},
importInbound() {
promptModal.open({
title: '{{ i18n "pages.inbounds.importInbound" }}',
type: 'textarea',
value: '',
okText: '{{ i18n "pages.inbounds.import" }}',
confirm: async (dbInboundText) => {
await this.submit('/panel/api/inbounds/import', {data: dbInboundText}, promptModal);
},
});
},
exportAllSubs() {
let subLinks = []
for (const dbInbound of this.dbInbounds) {
const clients = this.getInboundClients(dbInbound);
if (clients != null){
clients.forEach(c => {
if (c.subId && c.subId.length>0){
subLinks.push(this.subSettings.subURI + c.subId)
}
})
}
}
txtModal.show(
'{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
[...new Set(subLinks)].join('\r\n'),
'All-Inbounds-Subs');
},
exportAllLinks() {
let copyText = [];
for (const dbInbound of this.dbInbounds) {
copyText.push(dbInbound.genInboundLinks(this.remarkModel));
}
txtModal.show('{{ i18n "pages.inbounds.export"}}', copyText.join('\r\n'), 'All-Inbounds');
},
copy(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
txtModal.show('{{ i18n "pages.inbounds.inboundData" }}', JSON.stringify(dbInbound, null, 2));
},
async startDataRefreshLoop() {
while (this.isRefreshEnabled) {
try {
await this.getDBInbounds();
} catch (e) {
console.error(e);
}
await PromiseUtil.sleep(this.refreshInterval);
}
},
toggleRefresh() {
localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
},
changeRefreshInterval() {
localStorage.setItem("refreshInterval", this.refreshInterval);
},
async manualRefresh() {
if (!this.refreshing) {
this.loadingStates.spinning = true;
await this.getDBInbounds();
this.loadingStates.spinning = false;
}
},
pagination(obj){
if (this.pageSize > 0 && obj.length>this.pageSize) {
// Set page options based on object size
sizeOptions = [];
for (i=this.pageSize;i<=obj.length;i=i+this.pageSize) {
sizeOptions.push(i.toString());
}
// Add option to see all in one page
sizeOptions.push(i.toString());
p = {
showSizeChanger: true,
size: 'small',
position: 'bottom',
pageSize: this.pageSize,
pageSizeOptions: sizeOptions
};
return p;
}
return false
}
},
watch: {
searchKey: Utils.debounce(function (newVal) {
this.searchInbounds(newVal);
}, 500)
},
computed: {
total() {
let down = 0, up = 0, allTime = 0;
let clients = 0, deactive = [], depleted = [], expiring = [];
this.dbInbounds.forEach(dbInbound => {
down += dbInbound.down;
up += dbInbound.up;
allTime += (dbInbound.allTime || (dbInbound.up + dbInbound.down));
if (this.clientCount[dbInbound.id]) {
clients += this.clientCount[dbInbound.id].clients;
deactive = deactive.concat(this.clientCount[dbInbound.id].deactive);
depleted = depleted.concat(this.clientCount[dbInbound.id].depleted);
expiring = expiring.concat(this.clientCount[dbInbound.id].expiring);
}
});
return {
down: down,
up: up,
allTime: allTime,
clients: clients,
deactive: deactive,
depleted: depleted,
expiring: expiring,
};
}
},
});
</script>
{{ template "page/body_end" .}}