mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
942 lines
38 KiB
HTML
942 lines
38 KiB
HTML
{{ template "page/head_start" .}}
|
|
{{ template "page/head_end" .}}
|
|
|
|
{{ template "page/body_start" .}}
|
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' clients-page'">
|
|
<a-sidebar></a-sidebar>
|
|
<a-layout id="content-layout">
|
|
<a-layout-content :style="{ padding: '24px 16px' }">
|
|
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
|
<transition name="list" appear>
|
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
|
|
<a-col>
|
|
<a-card hoverable>
|
|
<template #title>
|
|
<a-space direction="horizontal">
|
|
<a-button type="primary" icon="plus" @click="openAddClient">
|
|
<template v-if="!isMobile">{{ i18n "pages.clients.addClient" }}</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="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-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="filterClients" 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="client => client.id"
|
|
:data-source="searchedClients" :scroll="isMobile ? {} : { x: 1000 }"
|
|
:pagination="false"
|
|
:style="{ marginTop: '10px' }"
|
|
class="clients-table"
|
|
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
|
|
<template slot="action" slot-scope="text, client">
|
|
<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, client)"
|
|
:theme="themeSwitcher.currentTheme">
|
|
<a-menu-item key="qrcode" v-if="client.inbounds && client.inbounds.length > 0">
|
|
<a-icon type="qrcode"></a-icon>
|
|
{{ i18n "qrCode" }}
|
|
</a-menu-item>
|
|
<a-menu-item key="edit">
|
|
<a-icon type="edit"></a-icon>
|
|
{{ i18n "edit" }}
|
|
</a-menu-item>
|
|
<a-menu-item key="resetTraffic">
|
|
<a-icon type="reload"></a-icon>
|
|
{{ i18n "pages.inbounds.resetTraffic" }}
|
|
</a-menu-item>
|
|
<a-menu-item key="delete" :style="{ color: '#FF4D4F' }">
|
|
<a-icon type="delete"></a-icon>
|
|
{{ i18n "delete" }}
|
|
</a-menu-item>
|
|
</a-menu>
|
|
</a-dropdown>
|
|
</template>
|
|
<template slot="email" slot-scope="text, client">
|
|
<span>[[ client.email || '-' ]]</span>
|
|
</template>
|
|
<template slot="inbounds" slot-scope="text, client">
|
|
<template v-if="client.inbounds && client.inbounds.length > 0">
|
|
<a-tag v-for="(inbound, index) in client.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
|
|
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
|
|
</a-tag>
|
|
</template>
|
|
<a-tag v-else color="default">{{ i18n "none" }}</a-tag>
|
|
</template>
|
|
<template slot="enable" slot-scope="text, client">
|
|
<a-switch v-model="client.enable" @change="switchEnable(client.id, client.enable)"></a-switch>
|
|
</template>
|
|
<template slot="status" slot-scope="text, client">
|
|
<a-tag v-if="isClientOnline(client.email)" color="green">{{ i18n "online" }}</a-tag>
|
|
<a-tag v-else color="default">{{ i18n "offline" }}</a-tag>
|
|
</template>
|
|
<template slot="traffic" slot-scope="text, client">
|
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
|
<template slot="content">
|
|
<table cellpadding="2" width="100%">
|
|
<tr>
|
|
<td>↓[[ SizeFormatter.sizeFormat(client.up || 0) ]]</td>
|
|
<td>↑[[ SizeFormatter.sizeFormat(client.down || 0) ]]</td>
|
|
</tr>
|
|
<tr v-if="getClientTotal(client) > 0 && (client.up || 0) + (client.down || 0) < getClientTotal(client)">
|
|
<td>{{ i18n "remained" }}</td>
|
|
<td>[[ SizeFormatter.sizeFormat(getClientTotal(client) - (client.up || 0) - (client.down || 0)) ]]</td>
|
|
</tr>
|
|
</table>
|
|
</template>
|
|
<a-tag :color="ColorUtils.usageColor((client.up || 0) + (client.down || 0), 0, getClientTotal(client))">
|
|
[[ SizeFormatter.sizeFormat((client.up || 0) + (client.down || 0)) ]] /
|
|
<template v-if="getClientTotal(client) > 0">
|
|
[[ SizeFormatter.sizeFormat(getClientTotal(client)) ]]
|
|
</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="expiryTime" slot-scope="text, client">
|
|
<a-popover v-if="client.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
|
|
<template slot="content">
|
|
[[ IntlUtil.formatDate(client.expiryTime) ]]
|
|
</template>
|
|
<a-tag :style="{ minWidth: '50px' }"
|
|
:color="ColorUtils.usageColor(new Date().getTime(), 0, client.expiryTime)">
|
|
[[ IntlUtil.formatRelativeTime(client.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>
|
|
</a-table>
|
|
</a-space>
|
|
</a-card>
|
|
</a-col>
|
|
</a-row>
|
|
<a-row v-else>
|
|
<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>
|
|
</transition>
|
|
</a-spin>
|
|
</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 "modals/qrcodeModal"}}
|
|
{{template "modals/clientEntityModal"}}
|
|
<script>
|
|
const columns = [{
|
|
title: "ID",
|
|
align: 'right',
|
|
dataIndex: "id",
|
|
width: 50,
|
|
}, {
|
|
title: '{{ i18n "pages.clients.operate" }}',
|
|
align: 'center',
|
|
width: 60,
|
|
scopedSlots: { customRender: 'action' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.email" }}',
|
|
align: 'left',
|
|
width: 200,
|
|
scopedSlots: { customRender: 'email' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.inbounds" }}',
|
|
align: 'left',
|
|
width: 250,
|
|
scopedSlots: { customRender: 'inbounds' },
|
|
}, {
|
|
title: '{{ i18n "status" }}',
|
|
align: 'center',
|
|
width: 100,
|
|
scopedSlots: { customRender: 'status' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.traffic" }}',
|
|
align: 'left',
|
|
width: 150,
|
|
scopedSlots: { customRender: 'traffic' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.expiryTime" }}',
|
|
align: 'left',
|
|
width: 120,
|
|
scopedSlots: { customRender: 'expiryTime' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.enable" }}',
|
|
align: 'center',
|
|
width: 80,
|
|
scopedSlots: { customRender: 'enable' },
|
|
}];
|
|
|
|
const mobileColumns = [{
|
|
title: "ID",
|
|
align: 'right',
|
|
dataIndex: "id",
|
|
width: 30,
|
|
}, {
|
|
title: '{{ i18n "pages.clients.operate" }}',
|
|
align: 'center',
|
|
width: 60,
|
|
scopedSlots: { customRender: 'action' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.email" }}',
|
|
align: 'left',
|
|
width: 150,
|
|
scopedSlots: { customRender: 'email' },
|
|
}, {
|
|
title: '{{ i18n "pages.clients.enable" }}',
|
|
align: 'center',
|
|
width: 60,
|
|
scopedSlots: { customRender: 'enable' },
|
|
}];
|
|
|
|
const app = window.app = new Vue({
|
|
delimiters: ['[[', ']]'],
|
|
el: '#app',
|
|
mixins: [MediaQueryMixin],
|
|
data: {
|
|
themeSwitcher,
|
|
loadingStates: {
|
|
fetched: false,
|
|
spinning: false
|
|
},
|
|
clients: [],
|
|
searchedClients: [],
|
|
allInbounds: [],
|
|
availableNodes: [],
|
|
refreshing: false,
|
|
onlineClients: [],
|
|
lastOnlineMap: {},
|
|
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
|
|
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
|
|
searchKey: '',
|
|
enableFilter: false,
|
|
filterBy: '',
|
|
expireDiff: 0,
|
|
trafficDiff: 0,
|
|
subSettings: {
|
|
enable: false,
|
|
subTitle: '',
|
|
subURI: '',
|
|
subJsonURI: '',
|
|
subJsonEnable: false,
|
|
},
|
|
remarkModel: '-ieo',
|
|
},
|
|
methods: {
|
|
loading(spinning = true) {
|
|
this.loadingStates.spinning = spinning;
|
|
},
|
|
async loadClients() {
|
|
this.refreshing = true;
|
|
try {
|
|
// Load online clients and last online map first
|
|
await this.getOnlineUsers();
|
|
await this.getLastOnlineMap();
|
|
|
|
const msg = await HttpUtil.get('/panel/client/list');
|
|
if (msg && msg.success && msg.obj) {
|
|
this.clients = msg.obj;
|
|
// Load inbounds for each client
|
|
await this.loadInboundsForClients();
|
|
// Apply current filter/search
|
|
if (this.enableFilter) {
|
|
this.filterClients();
|
|
} else {
|
|
this.searchClients(this.searchKey);
|
|
}
|
|
// Ensure searchedClients is initialized
|
|
if (this.searchedClients.length === 0 && this.clients.length > 0) {
|
|
this.searchedClients = this.clients.slice();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load clients:", e);
|
|
app.$message.error('{{ i18n "pages.clients.loadError" }}');
|
|
} finally {
|
|
this.refreshing = false;
|
|
this.loadingStates.fetched = true;
|
|
}
|
|
},
|
|
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 || {}
|
|
},
|
|
isClientOnline(email) {
|
|
return this.onlineClients.includes(email);
|
|
},
|
|
getLastOnline(email) {
|
|
return this.lastOnlineMap[email] || null
|
|
},
|
|
formatLastOnline(email) {
|
|
const ts = this.getLastOnline(email)
|
|
if (!ts) return '-'
|
|
// Check if IntlUtil is available (may not be loaded yet)
|
|
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
|
|
return IntlUtil.formatDate(ts)
|
|
}
|
|
// Fallback to simple date formatting if IntlUtil is not available
|
|
return new Date(ts).toLocaleString()
|
|
},
|
|
getClientTotal(client) {
|
|
// Convert TotalGB to bytes (1 GB = 1024^3 bytes)
|
|
// TotalGB can now be a decimal value (e.g., 0.01 for MB)
|
|
if (client.totalGB && client.totalGB > 0) {
|
|
return client.totalGB * 1024 * 1024 * 1024;
|
|
}
|
|
return 0;
|
|
},
|
|
async loadInboundsForClients() {
|
|
try {
|
|
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
|
|
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
|
|
this.allInbounds = inboundsMsg.obj;
|
|
// Map inbound IDs to full inbound objects for each client
|
|
this.clients.forEach(client => {
|
|
if (client.inboundIds && Array.isArray(client.inboundIds)) {
|
|
client.inbounds = client.inboundIds.map(id => {
|
|
return this.allInbounds.find(ib => ib.id === id);
|
|
}).filter(ib => ib != null);
|
|
} else {
|
|
client.inbounds = [];
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load inbounds for clients:", e);
|
|
}
|
|
},
|
|
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.subSettings = {
|
|
enable: subEnable,
|
|
subTitle: subTitle,
|
|
subURI: subURI,
|
|
subJsonURI: subJsonURI,
|
|
subJsonEnable: subJsonEnable,
|
|
};
|
|
this.remarkModel = remarkModel;
|
|
}
|
|
},
|
|
async loadAvailableNodes() {
|
|
try {
|
|
const msg = await HttpUtil.get("/panel/node/list");
|
|
if (msg && msg.success && msg.obj) {
|
|
this.availableNodes = msg.obj.map(node => ({
|
|
id: node.id,
|
|
name: node.name,
|
|
address: node.address,
|
|
status: node.status || 'unknown'
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load available nodes:", e);
|
|
}
|
|
},
|
|
clickAction(action, client) {
|
|
switch (action.key) {
|
|
case 'qrcode':
|
|
this.showQrcode(client);
|
|
break;
|
|
case 'edit':
|
|
this.editClient(client);
|
|
break;
|
|
case 'resetTraffic':
|
|
this.resetClientTraffic(client);
|
|
break;
|
|
case 'delete':
|
|
this.deleteClient(client.id);
|
|
break;
|
|
}
|
|
},
|
|
showQrcode(client) {
|
|
// Show QR codes for all inbounds assigned to this client
|
|
if (!client.inbounds || client.inbounds.length === 0) {
|
|
app.$message.warning('{{ i18n "tgbot.noInbounds" }}');
|
|
return;
|
|
}
|
|
|
|
// Convert ClientEntity to client format for qrModal
|
|
const clientForQR = {
|
|
email: client.email,
|
|
id: client.uuid || client.email,
|
|
password: client.password || '',
|
|
security: client.security || 'auto',
|
|
flow: client.flow || '',
|
|
subId: client.subId || '' // Add subId for subscription link generation
|
|
};
|
|
|
|
// Collect QR codes from all inbounds
|
|
const allQRCodes = [];
|
|
|
|
// Process each inbound assigned to this client
|
|
client.inbounds.forEach(inbound => {
|
|
if (!inbound) return;
|
|
|
|
// Load full inbound data to create DBInbound
|
|
const dbInbound = this.allInbounds.find(ib => ib.id === inbound.id);
|
|
if (!dbInbound) return;
|
|
|
|
// Create a DBInbound object from the inbound data
|
|
const dbInboundObj = new DBInbound(dbInbound);
|
|
const inboundObj = dbInboundObj.toInbound();
|
|
|
|
// Generate links for this inbound
|
|
// Get inbound remark (fallback to ID if remark is empty)
|
|
const inboundRemarkForWireguard = (dbInbound.remark && dbInbound.remark.trim()) || ('Inbound #' + dbInbound.id);
|
|
|
|
if (inboundObj.protocol == Protocols.WIREGUARD) {
|
|
inboundObj.genInboundLinks(dbInbound.remark).split('\r\n').forEach((l, index) => {
|
|
allQRCodes.push({
|
|
remark: inboundRemarkForWireguard + " - Peer " + (index + 1),
|
|
link: l,
|
|
useIPv4: false,
|
|
originalLink: l
|
|
});
|
|
});
|
|
} else {
|
|
const links = inboundObj.genAllLinks(dbInbound.remark, this.remarkModel, clientForQR);
|
|
const hasMultipleNodes = links.length > 1 && links.some(l => l.nodeId !== null);
|
|
const hasMultipleInbounds = client.inbounds.length > 1;
|
|
|
|
// Get inbound remark (fallback to ID if remark is empty)
|
|
const inboundRemark = (dbInbound.remark && dbInbound.remark.trim()) || ('Inbound #' + dbInbound.id);
|
|
|
|
links.forEach(l => {
|
|
// Build display remark - always start with inbound name
|
|
let displayRemark = inboundRemark;
|
|
|
|
// If multiple nodes, append node name
|
|
if (hasMultipleNodes && l.nodeId !== null) {
|
|
const node = this.availableNodes && this.availableNodes.find(n => n.id === l.nodeId);
|
|
if (node && node.name) {
|
|
displayRemark = inboundRemark + " - " + node.name;
|
|
}
|
|
}
|
|
|
|
// Ensure remark is never empty
|
|
if (!displayRemark || !displayRemark.trim()) {
|
|
displayRemark = 'Inbound #' + dbInbound.id;
|
|
}
|
|
|
|
allQRCodes.push({
|
|
remark: displayRemark,
|
|
link: l.link,
|
|
useIPv4: false,
|
|
originalLink: l.link,
|
|
nodeId: l.nodeId
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// If we have QR codes, show them in the modal
|
|
if (allQRCodes.length > 0) {
|
|
// Set up qrModal with first inbound (for subscription links if enabled)
|
|
const firstDbInbound = this.allInbounds.find(ib => ib.id === client.inbounds[0].id);
|
|
if (firstDbInbound) {
|
|
const firstDbInboundObj = new DBInbound(firstDbInbound);
|
|
|
|
// Set modal properties
|
|
qrModal.title = '{{ i18n "qrCode"}} - ' + client.email;
|
|
qrModal.dbInbound = firstDbInboundObj;
|
|
qrModal.inbound = firstDbInboundObj.toInbound();
|
|
qrModal.client = clientForQR;
|
|
qrModal.subId = clientForQR.subId || '';
|
|
|
|
// Clear and set qrcodes array - use Vue.set for reactivity if needed
|
|
qrModal.qrcodes.length = 0;
|
|
allQRCodes.forEach(qr => {
|
|
// Ensure remark is set and not empty
|
|
if (!qr.remark || !qr.remark.trim()) {
|
|
qr.remark = 'QR Code';
|
|
}
|
|
qrModal.qrcodes.push(qr);
|
|
});
|
|
|
|
// Show modal
|
|
qrModal.visible = true;
|
|
|
|
// Reset the status fetched flag
|
|
if (qrModalApp) {
|
|
qrModalApp.statusFetched = false;
|
|
}
|
|
}
|
|
} else {
|
|
app.$message.warning('{{ i18n "tgbot.noInbounds" }}');
|
|
}
|
|
},
|
|
openAddClient() {
|
|
// Call directly like inModal.show() in inbounds.html
|
|
if (typeof window.clientEntityModal !== 'undefined') {
|
|
window.clientEntityModal.show({
|
|
title: '{{ i18n "pages.clients.addClient" }}',
|
|
okText: '{{ i18n "create" }}',
|
|
confirm: async (client) => {
|
|
await this.submitClient(client, false);
|
|
},
|
|
isEdit: false
|
|
});
|
|
} else if (typeof clientEntityModal !== 'undefined') {
|
|
clientEntityModal.show({
|
|
title: '{{ i18n "pages.clients.addClient" }}',
|
|
okText: '{{ i18n "create" }}',
|
|
confirm: async (client) => {
|
|
await this.submitClient(client, false);
|
|
},
|
|
isEdit: false
|
|
});
|
|
} else {
|
|
console.error('[openAddClient] ERROR: clientEntityModal is not defined!');
|
|
}
|
|
},
|
|
async editClient(client) {
|
|
// Load full client data including HWIDs
|
|
try {
|
|
const msg = await HttpUtil.get(`/panel/client/get/${client.id}`);
|
|
if (msg && msg.success && msg.obj) {
|
|
client = msg.obj; // Use full client data from API
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load full client data:", e);
|
|
}
|
|
|
|
// Call directly like inModal.show() in inbounds.html
|
|
if (typeof window.clientEntityModal !== 'undefined') {
|
|
window.clientEntityModal.show({
|
|
title: '{{ i18n "pages.clients.editClient" }}',
|
|
okText: '{{ i18n "update" }}',
|
|
client: client,
|
|
confirm: async (client) => {
|
|
await this.submitClient(client, true);
|
|
},
|
|
isEdit: true
|
|
});
|
|
} else if (typeof clientEntityModal !== 'undefined') {
|
|
clientEntityModal.show({
|
|
title: '{{ i18n "pages.clients.editClient" }}',
|
|
okText: '{{ i18n "update" }}',
|
|
client: client,
|
|
confirm: async (client) => {
|
|
await this.submitClient(client, true);
|
|
},
|
|
isEdit: true
|
|
});
|
|
}
|
|
},
|
|
async submitClient(client, isEdit) {
|
|
if (!client.email || !client.email.trim()) {
|
|
app.$message.error('{{ i18n "pages.clients.emailRequired" }}');
|
|
return;
|
|
}
|
|
|
|
clientEntityModal.loading(true);
|
|
try {
|
|
// Convert date picker value to timestamp
|
|
if (client._expiryTime) {
|
|
if (moment && moment.isMoment(client._expiryTime)) {
|
|
client.expiryTime = client._expiryTime.valueOf();
|
|
} else if (client._expiryTime instanceof Date) {
|
|
client.expiryTime = client._expiryTime.getTime();
|
|
} else if (typeof client._expiryTime === 'number') {
|
|
client.expiryTime = client._expiryTime;
|
|
} else {
|
|
client.expiryTime = parseInt(client._expiryTime) || 0;
|
|
}
|
|
} else {
|
|
client.expiryTime = 0;
|
|
}
|
|
|
|
let msg;
|
|
if (isEdit) {
|
|
msg = await HttpUtil.post(`/panel/client/update/${client.id}`, client);
|
|
} else {
|
|
msg = await HttpUtil.post('/panel/client/add', client);
|
|
}
|
|
|
|
if (msg.success) {
|
|
app.$message.success(isEdit ? '{{ i18n "pages.clients.updateSuccess" }}' : '{{ i18n "pages.clients.addSuccess" }}');
|
|
clientEntityModal.close();
|
|
await this.loadClients();
|
|
} else {
|
|
app.$message.error(msg.msg || (isEdit ? '{{ i18n "pages.clients.updateError" }}' : '{{ i18n "pages.clients.addError" }}'));
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to submit client:", e);
|
|
app.$message.error(isEdit ? '{{ i18n "pages.clients.updateError" }}' : '{{ i18n "pages.clients.addError" }}');
|
|
} finally {
|
|
clientEntityModal.loading(false);
|
|
}
|
|
},
|
|
async deleteClient(id) {
|
|
this.$confirm({
|
|
title: '{{ i18n "pages.clients.deleteConfirm" }}',
|
|
content: '{{ i18n "pages.clients.deleteConfirmText" }}',
|
|
okText: '{{ i18n "sure" }}',
|
|
okType: 'danger',
|
|
cancelText: '{{ i18n "close" }}',
|
|
onOk: async () => {
|
|
try {
|
|
const msg = await HttpUtil.post(`/panel/client/del/${id}`);
|
|
if (msg.success) {
|
|
app.$message.success('{{ i18n "pages.clients.deleteSuccess" }}');
|
|
await this.loadClients();
|
|
} else {
|
|
app.$message.error(msg.msg || '{{ i18n "pages.clients.deleteError" }}');
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to delete client:", e);
|
|
app.$message.error('{{ i18n "pages.clients.deleteError" }}');
|
|
}
|
|
}
|
|
});
|
|
},
|
|
async switchEnable(id, enable) {
|
|
try {
|
|
const msg = await HttpUtil.post(`/panel/client/update/${id}`, { enable: enable });
|
|
if (msg.success) {
|
|
app.$message.success('{{ i18n "pages.clients.updateSuccess" }}');
|
|
} else {
|
|
app.$message.error(msg.msg || '{{ i18n "pages.clients.updateError" }}');
|
|
// Revert switch
|
|
const client = this.clients.find(c => c.id === id);
|
|
if (client) {
|
|
client.enable = !enable;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to update client:", e);
|
|
app.$message.error('{{ i18n "pages.clients.updateError" }}');
|
|
// Revert switch
|
|
const client = this.clients.find(c => c.id === id);
|
|
if (client) {
|
|
client.enable = !enable;
|
|
}
|
|
}
|
|
},
|
|
async startDataRefreshLoop() {
|
|
while (this.isRefreshEnabled) {
|
|
try {
|
|
await this.loadClients();
|
|
} 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.loadClients();
|
|
this.loadingStates.spinning = false;
|
|
}
|
|
},
|
|
searchClients(key) {
|
|
if (ObjectUtil.isEmpty(key)) {
|
|
this.searchedClients = this.clients.slice();
|
|
} else {
|
|
this.searchedClients.splice(0, this.searchedClients.length);
|
|
this.clients.forEach(client => {
|
|
if (ObjectUtil.deepSearch(client, key)) {
|
|
this.searchedClients.push(client);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
filterClients() {
|
|
if (ObjectUtil.isEmpty(this.filterBy)) {
|
|
this.searchedClients = this.clients.slice();
|
|
} else {
|
|
this.searchedClients.splice(0, this.searchedClients.length);
|
|
const now = new Date().getTime();
|
|
this.clients.forEach(client => {
|
|
let shouldInclude = false;
|
|
switch (this.filterBy) {
|
|
case 'deactive':
|
|
shouldInclude = !client.enable;
|
|
break;
|
|
case 'depleted':
|
|
const exhausted = client.totalGB > 0 && (client.up || 0) + (client.down || 0) >= client.totalGB * 1024 * 1024 * 1024;
|
|
const expired = client.expiryTime > 0 && client.expiryTime <= now;
|
|
shouldInclude = expired || exhausted;
|
|
break;
|
|
case 'expiring':
|
|
const expiringSoon = (client.expiryTime > 0 && (client.expiryTime - now < this.expireDiff)) ||
|
|
(client.totalGB > 0 && (client.totalGB * 1024 * 1024 * 1024 - (client.up || 0) - (client.down || 0) < this.trafficDiff));
|
|
shouldInclude = expiringSoon && !this.isClientDepleted(client);
|
|
break;
|
|
case 'online':
|
|
shouldInclude = this.isClientOnline(client.email);
|
|
break;
|
|
}
|
|
if (shouldInclude) {
|
|
this.searchedClients.push(client);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
toggleFilter() {
|
|
if (this.enableFilter) {
|
|
this.searchKey = '';
|
|
} else {
|
|
this.filterBy = '';
|
|
this.searchedClients = this.clients.slice();
|
|
}
|
|
},
|
|
isClientDepleted(client) {
|
|
const now = new Date().getTime();
|
|
const exhausted = client.totalGB > 0 && (client.up || 0) + (client.down || 0) >= client.totalGB * 1024 * 1024 * 1024;
|
|
const expired = client.expiryTime > 0 && client.expiryTime <= now;
|
|
return expired || exhausted;
|
|
},
|
|
generalActions(action) {
|
|
switch (action.key) {
|
|
case "resetClients":
|
|
this.resetAllClientTraffics();
|
|
break;
|
|
case "delDepletedClients":
|
|
this.delDepletedClients();
|
|
break;
|
|
}
|
|
},
|
|
resetAllClientTraffics() {
|
|
this.$confirm({
|
|
title: '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
|
|
content: '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
|
|
class: themeSwitcher.currentTheme,
|
|
okText: '{{ i18n "reset"}}',
|
|
cancelText: '{{ i18n "cancel"}}',
|
|
onOk: async () => {
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/client/resetAllTraffics');
|
|
if (msg.success) {
|
|
app.$message.success('{{ i18n "pages.inbounds.toasts.resetAllClientTrafficSuccess" }}');
|
|
await this.loadClients();
|
|
} else {
|
|
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to reset all client traffics:", e);
|
|
app.$message.error('{{ i18n "somethingWentWrong" }}');
|
|
}
|
|
}
|
|
});
|
|
},
|
|
resetClientTraffic(client) {
|
|
this.$confirm({
|
|
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
|
|
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
|
|
class: themeSwitcher.currentTheme,
|
|
okText: '{{ i18n "reset"}}',
|
|
cancelText: '{{ i18n "cancel"}}',
|
|
onOk: async () => {
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/client/resetTraffic/' + client.id);
|
|
if (msg.success) {
|
|
app.$message.success('{{ i18n "pages.inbounds.toasts.resetInboundClientTrafficSuccess" }}');
|
|
await this.loadClients();
|
|
} else {
|
|
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to reset client traffic:", e);
|
|
app.$message.error('{{ i18n "somethingWentWrong" }}');
|
|
}
|
|
}
|
|
});
|
|
},
|
|
delDepletedClients() {
|
|
this.$confirm({
|
|
title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
|
|
content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
|
|
class: themeSwitcher.currentTheme,
|
|
okText: '{{ i18n "delete"}}',
|
|
cancelText: '{{ i18n "cancel"}}',
|
|
onOk: async () => {
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/client/delDepletedClients');
|
|
if (msg.success) {
|
|
app.$message.success('{{ i18n "pages.inbounds.toasts.delDepletedClientsSuccess" }}');
|
|
await this.loadClients();
|
|
} else {
|
|
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to delete depleted clients:", e);
|
|
app.$message.error('{{ i18n "somethingWentWrong" }}');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
},
|
|
async mounted() {
|
|
// Load default settings (subSettings, remarkModel) first
|
|
await this.getDefaultSettings();
|
|
// Load available nodes for proper host addresses in QR codes
|
|
await this.loadAvailableNodes();
|
|
this.loading();
|
|
|
|
// Initial data fetch
|
|
this.loadClients().then(() => {
|
|
this.loading(false);
|
|
// Initialize searchedClients after first load
|
|
this.searchedClients = this.clients.slice();
|
|
});
|
|
|
|
// Setup WebSocket for real-time updates
|
|
if (window.wsClient) {
|
|
window.wsClient.connect();
|
|
|
|
// Listen for inbounds updates (contains full client traffic data)
|
|
window.wsClient.on('inbounds', (payload) => {
|
|
if (payload && Array.isArray(payload)) {
|
|
// Update traffic for clients from inbounds data
|
|
// This is more efficient than reloading all clients
|
|
if (!this.refreshing) {
|
|
this.refreshing = true;
|
|
// Silently reload clients to get updated traffic
|
|
this.loadClients().finally(() => {
|
|
this.refreshing = false;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Listen for traffic updates
|
|
window.wsClient.on('traffic', (payload) => {
|
|
// Update online clients list in real-time
|
|
if (payload && Array.isArray(payload.onlineClients)) {
|
|
this.onlineClients = payload.onlineClients;
|
|
}
|
|
|
|
// Update last online map in real-time
|
|
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
|
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
|
|
}
|
|
|
|
// Note: Traffic updates (up/down) are handled via 'inbounds' event
|
|
// which contains full accumulated traffic data from database
|
|
});
|
|
|
|
// Fallback to polling if WebSocket fails
|
|
window.wsClient.on('error', () => {
|
|
console.warn('WebSocket connection failed, falling back to polling');
|
|
if (this.isRefreshEnabled) {
|
|
this.startDataRefreshLoop();
|
|
}
|
|
});
|
|
|
|
window.wsClient.on('disconnected', () => {
|
|
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
|
|
console.warn('WebSocket reconnection failed, falling back to polling');
|
|
if (this.isRefreshEnabled) {
|
|
this.startDataRefreshLoop();
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// Fallback to polling if WebSocket is not available
|
|
if (this.isRefreshEnabled) {
|
|
this.startDataRefreshLoop();
|
|
}
|
|
}
|
|
},
|
|
watch: {
|
|
searchKey: Utils.debounce(function (newVal) {
|
|
this.searchClients(newVal);
|
|
}, 500)
|
|
}
|
|
});
|
|
</script>
|
|
{{ template "page/body_end" .}}
|