Y-Panel/web/html/inbounds.html
心隨緣動 6fd483f648
v25.9.17
v25.9.17
2025-09-17 23:45:33 +08:00

2187 lines
109 KiB
HTML
Raw 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="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"
:ok-text="`{{ i18n "pages.inbounds.confirmCreate" }}`"
:cancel-text="`{{ i18n "cancel" }}`"
:class="themeSwitcher.currentTheme"
@ok="handleOneClickOk"
@cancel="handleOneClickCancel"
>
<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;">
{{ i18n "pages.inbounds.oneClick.preset.vless_reality" }}
</a-button>
<a-button @click="handleOneClickOk('vless_tls_encryption')" block style="margin-bottom: 10px;">
{{ 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>{{ i18n "pages.inbounds.oneClick.descriptionContent" }}</p>
</div>
<div v-else>
<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: (text) => (text > 0 ? text : {{ i18n "pages.inbounds.unlimited" }}),
}, {
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',
'microsoft.com:443',
'apple.com:443',
'icloud.com:443',
'amazon.com:443',
'nintendo.com:443',
'lovelive-anime.jp:443',
'meta.com:443',
'stanford.edu:443',
],
},
mounted() {
this.getDefaultSettings();
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
} else {
this.getDBInbounds();
}
// 【新增】: 从本地存储加载历史记录
const history = localStorage.getItem('oneClickLinkHistory');
if (history) {
this.linkHistory = JSON.parse(history);
}
},
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();
}
},
// 【修改后】: 按照固定长度 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;
},
// 【新增】: 保存链接到历史记录,并同步到本地存储
saveLinkToHistory(type, link) {
const newRecord = { type: type, link: link, time: new Date().toLocaleString() };
this.linkHistory.unshift(newRecord); // 添加到数组开头
if (this.linkHistory.length > 10) {
this.linkHistory.pop(); // 保持最多10条记录
}
localStorage.setItem('oneClickLinkHistory', JSON.stringify(this.linkHistory));
},
// 【中文注释】: 以下是新增和修改后的方法,用于处理“一键配置”和“订阅转换”
// 【中文注释】: 打开“一键配置”模态框
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 === '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;
// 【中文注释】: 从预设列表中随机选择一个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,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid, // 【中文注释】: 使用后端生成的 UUID
flow: "xtls-rprx-vision",
email: remark,
level: 0
}],
decryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
realitySettings: {
show: false,
target: randomDest,
xver: 0,
serverNames: [ randomSni, `www.${randomSni}` ],
privateKey: privateKey,
publicKey: publicKey,
maxClientVer: "",
minClientVer: "",
maxTimediff: 0,
mldsa65Seed: "", // 【中文注释】: 一般情况下【留空】
"shortIds": shortIds,
"spiderX": "/"
},
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.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("");
// 随机生成 10000-55535 之间的端口
const port = Math.floor(Math.random() * (55535 - 10000 + 1)) + 10000;
// 【重要】: 随机选择 SNI
const randomDest = this.realityDestinations[Math.floor(Math.random() * this.realityDestinations.length)];
const serverName = randomDest.split(':')[0]; // 从域名列表中提取 SNI
// 【必须】: 动态设置证书路径
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,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid, // 【中文注释】: 使用后端生成的 UUID
flow: "xtls-rprx-vision",
email: remark,
level: 0
}],
decryption: decryption, // 使用从 API 获取的 decryption
encryption: encryption, // 【重要】: 加上 encryption 参数
selectedAuth: "ML-KEM-768, Post-Quantum"
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "tls",
tcpSettings: {
acceptProxyProtocol: false,
header: {
type: "none"
}
},
tlsSettings: {
alpn: ["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"]
}
}),
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.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('创建过程中发生网络错误,请检查后端日志。');
}
},
async copyLink(linkToCopy) {
const link = linkToCopy || this.inboundLink;
if (!link) {
this.$message.warning('没有可复制的链接。');
return;
}
try {
await navigator.clipboard.writeText(link);
this.$message.success('链接已成功复制到剪贴板!');
} catch (err) {
this.$message.error('复制失败,请手动复制。');
}
},
// 【中文注释】: 处理“订阅转换”按钮的点击事件 (已更新逻辑)
handleSubscriptionConversion() {
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('正在检测订阅转换服务...', 0);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
fetch(subConverterUrl, {
method: 'GET',
mode: 'no-cors',
signal: controller.signal
})
.then(response => {
clearTimeout(timeoutId);
loadingMessage();
this.$message.success('服务检测成功,正在跳转...', 2);
window.open(subConverterUrl, '_blank');
})
.catch(err => {
clearTimeout(timeoutId);
loadingMessage();
this.$info({
title: '{{ i18n "pages.inbounds.subConversion.notFoundTitle"}}',
content: '{{ i18n "pages.inbounds.subConversion.notFoundContent"}}',
class: themeSwitcher.currentTheme,
});
});
},
});
},
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) {
// 先把 dbInbound 转成 inbound 对象
const inbound = dbInbound.toInbound();
const clients = inbound.clients || [];
// 遍历 clients保证 speedLimit 字段存在且是数字
return clients.map(c => ({
...c, // 保留原有字段
speedLimit: Number(c.speedLimit ?? 0), // 如果没设置就默认为 0
}));
},
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)
},
mounted() {
if (window.location.protocol !== "https:") {
this.showAlert = true;
}
this.loading();
this.getDefaultSettings();
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
else {
this.getDBInbounds();
}
this.loading(false);
},
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" .}}