Files
Y-Panel/web/html/inbounds.html
T
心隨緣動 4354efb974
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

{{ 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" .}}