mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat(clients): add Delete depleted action
Mirrors the legacy delDepletedClients action that lived under the inbounds page, but as a first-class /panel/api/clients/delDepleted endpoint backed by ClientService. The new path goes through ClientService.Delete for each depleted email, so the new clients + client_inbounds + xray_client_traffic tables stay consistent. Adds a danger-styled toolbar button on the Clients page (next to Reset all client traffic) with a confirm dialog and a toast reporting the deleted count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c5217b9a78
commit
c84799ea2b
6 changed files with 100 additions and 1 deletions
|
|
@ -597,6 +597,12 @@ export const sections = [
|
|||
summary: 'Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved.',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/delDepleted',
|
||||
summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
|
||||
response: '{\n "success": true,\n "obj": {\n "deleted": 0\n }\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
InfoCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
RetweetOutlined,
|
||||
RestOutlined,
|
||||
MoreOutlined,
|
||||
UsergroupAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
|
@ -40,6 +41,7 @@ const {
|
|||
detach,
|
||||
resetTraffic,
|
||||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
} = useClients();
|
||||
|
||||
|
|
@ -140,6 +142,25 @@ async function onBulkAddSaved() {
|
|||
bulkAddOpen.value = false;
|
||||
}
|
||||
|
||||
function onDelDepleted() {
|
||||
Modal.confirm({
|
||||
title: t('pages.clients.delDepletedConfirmTitle') || 'Delete depleted clients?',
|
||||
content: t('pages.clients.delDepletedConfirmContent')
|
||||
|| 'Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.',
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await delDepleted();
|
||||
if (msg?.success) {
|
||||
const deleted = msg.obj?.deleted ?? 0;
|
||||
message.success(t('pages.clients.toasts.delDepleted', { count: deleted })
|
||||
|| `${deleted} depleted clients deleted`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onlineSet = computed(() => new Set(onlines.value || []));
|
||||
const inboundsById = computed(() => {
|
||||
const out = {};
|
||||
|
|
@ -343,6 +364,12 @@ const columns = computed(() => [
|
|||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
|
||||
</a-button>
|
||||
<a-button size="small" danger @click="onDelDepleted">
|
||||
<template #icon>
|
||||
<RestOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.clients.delDepleted') || 'Delete depleted' }}</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ export function useClients() {
|
|||
return msg;
|
||||
}
|
||||
|
||||
async function delDepleted() {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function setEnable(client, enable) {
|
||||
if (!client?.id) return null;
|
||||
const payload = {
|
||||
|
|
@ -142,6 +148,7 @@ export function useClients() {
|
|||
detach,
|
||||
resetTraffic,
|
||||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/:id/attach", a.attach)
|
||||
g.POST("/:id/detach", a.detach)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/delDepleted", a.delDepleted)
|
||||
}
|
||||
|
||||
func (a *ClientController) list(c *gin.Context) {
|
||||
|
|
@ -155,6 +156,18 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) delDepleted(c *gin.Context) {
|
||||
deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"deleted": deleted}, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) detach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -475,6 +475,48 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
|||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
|
||||
db := database.GetDB()
|
||||
now := time.Now().UnixMilli()
|
||||
depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
|
||||
|
||||
var rows []xray.ClientTraffic
|
||||
if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
emails := make(map[string]struct{}, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Email != "" {
|
||||
emails[r.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
deleted := 0
|
||||
for email := range emails {
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return deleted, needRestart, err
|
||||
}
|
||||
nr, err := s.Delete(inboundSvc, rec.Id, false)
|
||||
if err != nil {
|
||||
return deleted, needRestart, err
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetAllTraffics() (bool, error) {
|
||||
res := database.GetDB().Model(&xray.ClientTraffic{}).
|
||||
Where("1 = 1").
|
||||
|
|
|
|||
|
|
@ -440,12 +440,16 @@
|
|||
"deleteSelected": "Delete ({count})",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} clients?",
|
||||
"bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
|
||||
"delDepleted": "Delete depleted",
|
||||
"delDepletedConfirmTitle": "Delete depleted clients?",
|
||||
"delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
|
||||
"toasts": {
|
||||
"deleted": "Client deleted",
|
||||
"trafficReset": "Traffic reset",
|
||||
"allTrafficsReset": "All client traffic reset",
|
||||
"bulkDeleted": "{count} clients deleted",
|
||||
"bulkCreated": "{count} clients created"
|
||||
"bulkCreated": "{count} clients created",
|
||||
"delDepleted": "{count} depleted clients deleted"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue