mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat: add batch select and batch edit for inbound clients
This commit is contained in:
parent
fb7beb4dd2
commit
18bdc2baa5
7 changed files with 694 additions and 1 deletions
60
docs/Tasktracking/2026-04-26-batch-edit-clients.md
Normal file
60
docs/Tasktracking/2026-04-26-batch-edit-clients.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
Task Record: Batch Edit Clients
|
||||||
|
|
||||||
|
Date: 2026-04-26
|
||||||
|
Related Module: Client management (web/service, web/controller, web/html)
|
||||||
|
Change Type: Add
|
||||||
|
|
||||||
|
Background
|
||||||
|
|
||||||
|
Users previously had to edit each client individually to change shared settings (flow, limit IP, total GB, expiry time, enable state, Telegram ID, comment, reset period). This was time-consuming when managing many clients in an inbound.
|
||||||
|
|
||||||
|
Changes
|
||||||
|
|
||||||
|
Added batch multi-select and batch editing for clients in the inbound expanded view:
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- New `BatchUpdateInboundClients` service method in `web/service/inbound.go` - updates multiple clients' common fields in one transaction, syncs client_traffics table (total, expiry_time, enable, reset, tg_id) and Xray API (enable/disable)
|
||||||
|
- New `BatchUpdateInboundClientsForUser` authorization wrapper
|
||||||
|
- New `POST /panel/api/inbounds/batchUpdateClients` API route in `web/controller/inbound.go`
|
||||||
|
- Added `toInt64` helper for JSON number type conversion
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- New `client_batch_edit_modal.html` modal for batch editing common fields (enable, security, flow, limitIP, totalGB, expiryTime, delayedStart, reset, tgId, comment) with "keep unchanged" defaults
|
||||||
|
- Modified `inbounds.html` expanded row: added row-selection to client table, batch action bar (batch edit, enable/disable, reset traffic, delete, deselect)
|
||||||
|
- Added `clientSelection` reactive state and helper methods (`getClientRowKey`, `getClientRowSelection`, `getSelectedClients`, `openBatchEditClient`, `batchEnableClient`, `batchResetClientTraffic`, `batchDelClient`, `clearClientSelection`)
|
||||||
|
|
||||||
|
Translations:
|
||||||
|
- Added keys to `translate.en_US.toml` and `translate.zh_CN.toml`: batchEdit, batchEditAlert, batchKeep, batchEditNoFields, batchDeselect, batchDeleteLastClient, selected
|
||||||
|
|
||||||
|
Fields explicitly NOT batch-editable (enforced both frontend and backend):
|
||||||
|
- email (unique identifier for traffic tracking)
|
||||||
|
- id (protocol-specific client UUID)
|
||||||
|
- subId (subscription identifier)
|
||||||
|
- password (Trojan client password)
|
||||||
|
|
||||||
|
Impact
|
||||||
|
|
||||||
|
Affected files:
|
||||||
|
- `web/service/inbound.go` (+~210 lines)
|
||||||
|
- `web/controller/inbound.go` (+~30 lines)
|
||||||
|
- `web/html/inbounds.html` (~+90 lines in methods, +15 lines in template)
|
||||||
|
- `web/html/modals/client_batch_edit_modal.html` (new, 209 lines)
|
||||||
|
- `web/translation/translate.en_US.toml` (+8 keys)
|
||||||
|
- `web/translation/translate.zh_CN.toml` (+8 keys)
|
||||||
|
|
||||||
|
No database schema changes. No config changes. No breaking API changes (new route only).
|
||||||
|
|
||||||
|
Verification
|
||||||
|
|
||||||
|
- `go build ./...` - passes
|
||||||
|
- `CGO_ENABLED=1 go build` - passes
|
||||||
|
- `go vet ./...` - passes
|
||||||
|
- `gofmt -d` - no formatting issues
|
||||||
|
|
||||||
|
Not verified: runtime integration testing (requires running panel with Xray and clients).
|
||||||
|
|
||||||
|
Risks And Follow-Up
|
||||||
|
|
||||||
|
- Batch delete does not prevent deleting the LAST client (only warns when trying to delete ALL remaining). The individual delete button already shows/hides based on `isRemovable()`.
|
||||||
|
- If the Xray API call fails during batch enable/disable, the service returns `needRestart=true` which triggers Xray restart by the periodic check.
|
||||||
|
- The batch edit modal resets all field values when reopened; no persistence across invocations.
|
||||||
|
|
@ -55,6 +55,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/lastOnline", a.lastOnline)
|
g.POST("/lastOnline", a.lastOnline)
|
||||||
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
||||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||||
|
g.POST("/batchUpdateClients", a.batchUpdateInboundClients)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||||
|
|
@ -470,6 +471,32 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// batchUpdateInboundClients updates multiple clients in an inbound with the same field changes.
|
||||||
|
func (a *InboundController) batchUpdateInboundClients(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
InboundID int `json:"inboundId"`
|
||||||
|
ClientIDs []string `json:"clientIds"`
|
||||||
|
UpdateFields string `json:"updateFields"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
needRestart, err := a.inboundService.BatchUpdateInboundClientsForUser(
|
||||||
|
user.Id, user.Role == "admin", request.InboundID, request.ClientIDs, request.UpdateFields,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getUserInfo returns client traffic information for the logged-in user.
|
// getUserInfo returns client traffic information for the logged-in user.
|
||||||
func (a *InboundController) getUserInfo(c *gin.Context) {
|
func (a *InboundController) getUserInfo(c *gin.Context) {
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
|
|
|
||||||
|
|
@ -584,8 +584,21 @@
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
<template slot="expandedRowRender" slot-scope="record">
|
<template slot="expandedRowRender" slot-scope="record">
|
||||||
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
|
<div v-if="clientSelection[record.id] && clientSelection[record.id].length > 0 && record.isMultiUser()" :style="{ marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' }">
|
||||||
|
<span :style="{ marginInlineEnd: '8px' }">[[ clientSelection[record.id].length ]] {{ i18n "selected" }}</span>
|
||||||
|
<a-button size="small" type="primary" @click="openBatchEditClient(record.id)">{{ i18n "pages.client.batchEdit" }}</a-button>
|
||||||
|
<a-button size="small" @click="batchEnableClient(record.id, true)">{{ i18n "enable" }}</a-button>
|
||||||
|
<a-button size="small" @click="batchEnableClient(record.id, false)">{{ i18n "disabled" }}</a-button>
|
||||||
|
<a-button size="small" @click="batchResetClientTraffic(record.id)">{{ i18n "pages.inbounds.resetTraffic" }}</a-button>
|
||||||
|
<a-popconfirm @confirm="batchDelClient(record.id)" title='{{ i18n "pages.inbounds.deleteClientContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'>
|
||||||
|
<a-icon slot="icon" type="question-circle-o" :style="{ color: '#e04141' }"></a-icon>
|
||||||
|
<a-button size="small" type="danger">{{ i18n "delete" }}</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-button size="small" @click="clearClientSelection(record.id)">{{ i18n "pages.client.batchDeselect" }}</a-button>
|
||||||
|
</div>
|
||||||
|
<a-table :row-key="client => getClientRowKey(record.protocol, client)" :columns="isMobile ? innerMobileColumns : innerColumns"
|
||||||
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
|
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
|
||||||
|
:row-selection="getClientRowSelection(record)"
|
||||||
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
|
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
|
||||||
{{template "component/aClientTable"}}
|
{{template "component/aClientTable"}}
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
@ -617,6 +630,7 @@
|
||||||
{{template "modals/inboundInfoModal"}}
|
{{template "modals/inboundInfoModal"}}
|
||||||
{{template "modals/clientsModal"}}
|
{{template "modals/clientsModal"}}
|
||||||
{{template "modals/clientsBulkModal"}}
|
{{template "modals/clientsBulkModal"}}
|
||||||
|
{{template "modals/clientBatchEditModal"}}
|
||||||
<script>
|
<script>
|
||||||
const columns = [{
|
const columns = [{
|
||||||
title: "ID",
|
title: "ID",
|
||||||
|
|
@ -761,6 +775,7 @@
|
||||||
currentUsername: {{ printf "%q" .current_username }},
|
currentUsername: {{ printf "%q" .current_username }},
|
||||||
clientEmailOptions: [],
|
clientEmailOptions: [],
|
||||||
loadFailed: false,
|
loadFailed: false,
|
||||||
|
clientSelection: {},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loading(spinning = true) {
|
loading(spinning = true) {
|
||||||
|
|
@ -1612,6 +1627,115 @@
|
||||||
this.loadingStates.spinning = false;
|
this.loadingStates.spinning = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getClientRowKey(protocol, client) {
|
||||||
|
switch (protocol) {
|
||||||
|
case Protocols.TROJAN: return 'p_' + client.password;
|
||||||
|
case Protocols.SHADOWSOCKS: return 'e_' + client.email;
|
||||||
|
default: return client.id;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getClientRowSelection(dbInbound) {
|
||||||
|
if (!dbInbound || !dbInbound.isMultiUser()) return null;
|
||||||
|
const inboundId = dbInbound.id;
|
||||||
|
return {
|
||||||
|
selectedRowKeys: this.clientSelection[inboundId] || [],
|
||||||
|
onChange: (selectedRowKeys) => {
|
||||||
|
this.$set(this.clientSelection, inboundId, selectedRowKeys);
|
||||||
|
},
|
||||||
|
columnWidth: '32px',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSelectedClients(inboundId) {
|
||||||
|
const dbInbound = this.dbInbounds.find(row => row.id === inboundId);
|
||||||
|
if (!dbInbound) return [];
|
||||||
|
const clients = this.getInboundClients(dbInbound);
|
||||||
|
if (!clients || !Array.isArray(clients)) return [];
|
||||||
|
const selectedKeys = this.clientSelection[inboundId] || [];
|
||||||
|
return clients.filter(c => selectedKeys.includes(this.getClientRowKey(dbInbound.protocol, c)));
|
||||||
|
},
|
||||||
|
clearClientSelection(inboundId) {
|
||||||
|
this.$set(this.clientSelection, inboundId, []);
|
||||||
|
},
|
||||||
|
openBatchEditClient(inboundId) {
|
||||||
|
const dbInbound = this.dbInbounds.find(row => row.id === inboundId);
|
||||||
|
if (!dbInbound) return;
|
||||||
|
const selectedClients = this.getSelectedClients(inboundId);
|
||||||
|
if (selectedClients.length === 0) return;
|
||||||
|
const clientIds = selectedClients.map(c => this.getClientId(dbInbound.protocol, c));
|
||||||
|
clientBatchEditModal.show({
|
||||||
|
title: '{{ i18n "pages.client.batchEdit" }} - ' + dbInbound.remark,
|
||||||
|
okText: '{{ i18n "pages.client.submitEdit" }}',
|
||||||
|
dbInbound: dbInbound,
|
||||||
|
selectedCount: selectedClients.length,
|
||||||
|
confirm: async (updateData, dbInboundId) => {
|
||||||
|
clientBatchEditModal.loading();
|
||||||
|
const data = {
|
||||||
|
inboundId: dbInboundId,
|
||||||
|
clientIds: clientIds,
|
||||||
|
updateFields: JSON.stringify(updateData),
|
||||||
|
};
|
||||||
|
await this.submit('/panel/api/inbounds/batchUpdateClients', data, clientBatchEditModal);
|
||||||
|
this.clearClientSelection(inboundId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async batchEnableClient(inboundId, enable) {
|
||||||
|
const dbInbound = this.dbInbounds.find(row => row.id === inboundId);
|
||||||
|
if (!dbInbound) return;
|
||||||
|
const selectedClients = this.getSelectedClients(inboundId);
|
||||||
|
if (selectedClients.length === 0) return;
|
||||||
|
const clientIds = selectedClients.map(c => this.getClientId(dbInbound.protocol, c));
|
||||||
|
this.loading();
|
||||||
|
const data = {
|
||||||
|
inboundId: inboundId,
|
||||||
|
clientIds: clientIds,
|
||||||
|
updateFields: JSON.stringify({ enable: enable }),
|
||||||
|
};
|
||||||
|
await this.submit('/panel/api/inbounds/batchUpdateClients', data, null);
|
||||||
|
this.clearClientSelection(inboundId);
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
batchResetClientTraffic(inboundId) {
|
||||||
|
const selectedClients = this.getSelectedClients(inboundId);
|
||||||
|
if (selectedClients.length === 0) return;
|
||||||
|
this.$confirm({
|
||||||
|
title: '{{ i18n "pages.inbounds.resetTraffic"}} (' + selectedClients.length + ' {{ i18n "clients" }})',
|
||||||
|
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
|
||||||
|
class: themeSwitcher.currentTheme,
|
||||||
|
okText: '{{ i18n "reset"}}',
|
||||||
|
cancelText: '{{ i18n "cancel"}}',
|
||||||
|
onOk: async () => {
|
||||||
|
this.loading();
|
||||||
|
for (const client of selectedClients) {
|
||||||
|
await HttpUtil.post('/panel/api/inbounds/' + inboundId + '/resetClientTraffic/' + client.email);
|
||||||
|
}
|
||||||
|
this.clearClientSelection(inboundId);
|
||||||
|
await this.getDBInbounds();
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
batchDelClient(inboundId) {
|
||||||
|
const dbInbound = this.dbInbounds.find(row => row.id === inboundId);
|
||||||
|
if (!dbInbound) return;
|
||||||
|
const selectedClients = this.getSelectedClients(inboundId);
|
||||||
|
if (selectedClients.length === 0) return;
|
||||||
|
const remainingClients = this.getInboundClients(dbInbound);
|
||||||
|
if (remainingClients && remainingClients.length - selectedClients.length < 1) {
|
||||||
|
this.$message.warning('{{ i18n "pages.client.batchDeleteLastClient" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading();
|
||||||
|
const promises = selectedClients.map(async (client) => {
|
||||||
|
const clientId = this.getClientId(dbInbound.protocol, client);
|
||||||
|
return HttpUtil.post('/panel/api/inbounds/' + inboundId + '/delClient/' + clientId);
|
||||||
|
});
|
||||||
|
Promise.all(promises).then(() => {
|
||||||
|
this.clearClientSelection(inboundId);
|
||||||
|
this.getDBInbounds();
|
||||||
|
this.loading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
pagination(obj) {
|
pagination(obj) {
|
||||||
if (this.pageSize > 0 && obj.length > this.pageSize) {
|
if (this.pageSize > 0 && obj.length > this.pageSize) {
|
||||||
// Set page options based on object size
|
// Set page options based on object size
|
||||||
|
|
|
||||||
209
web/html/modals/client_batch_edit_modal.html
Normal file
209
web/html/modals/client_batch_edit_modal.html
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
{{define "modals/clientBatchEditModal"}}
|
||||||
|
<a-modal id="client-batch-edit-modal" v-model="clientBatchEditModal.visible" :title="clientBatchEditModal.title"
|
||||||
|
@ok="clientBatchEditModal.ok" :confirm-loading="clientBatchEditModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||||
|
:ok-text="clientBatchEditModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||||
|
<a-alert type="warning" :message="clientBatchEditModal.selectedCount + ' {{ i18n "pages.client.batchEditAlert" }}'" :style="{ marginBottom: '16px' }" banner show-icon></a-alert>
|
||||||
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
|
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
|
||||||
|
<a-radio-group v-model="clientBatchEditModal.enable" button-style="solid">
|
||||||
|
<a-radio-button :value="null">{{ i18n "pages.client.batchKeep" }}</a-radio-button>
|
||||||
|
<a-radio-button :value="true">{{ i18n "enable" }}</a-radio-button>
|
||||||
|
<a-radio-button :value="false">{{ i18n "disabled" }}</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label='{{ i18n "security" }}'>
|
||||||
|
<a-select v-model="clientBatchEditModal.security" :dropdown-class-name="themeSwitcher.currentTheme" allow-clear :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'>
|
||||||
|
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='Flow' v-if="clientBatchEditModal.inbound && clientBatchEditModal.inbound.canEnableTlsFlow()">
|
||||||
|
<a-select v-model="clientBatchEditModal.flow" :dropdown-class-name="themeSwitcher.currentTheme" allow-clear :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'>
|
||||||
|
<a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
|
||||||
|
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="app.ipLimitEnable">
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.inbounds.IPLimitDesc" }}</span>
|
||||||
|
</template>
|
||||||
|
<span>{{ i18n "pages.inbounds.IPLimit" }} </span>
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model.number="clientBatchEditModal.limitIp" :min="0" :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
|
||||||
|
</template>
|
||||||
|
{{ i18n "pages.inbounds.totalFlow" }}
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model.number="clientBatchEditModal.totalGB" :min="0" :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||||
|
<a-radio-group v-model="clientBatchEditModal.delayedStart" button-style="solid">
|
||||||
|
<a-radio-button :value="null">{{ i18n "pages.client.batchKeep" }}</a-radio-button>
|
||||||
|
<a-radio-button :value="true">{{ i18n "enable" }}</a-radio-button>
|
||||||
|
<a-radio-button :value="false">{{ i18n "disabled" }}</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.client.expireDays" }}' v-if="clientBatchEditModal.delayedStart === true">
|
||||||
|
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-else-if="clientBatchEditModal.delayedStart === false">
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</span>
|
||||||
|
</template>
|
||||||
|
{{ i18n "pages.inbounds.expireDate" }}
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
v-model="clientBatchEditModal.expiryTime"></a-date-picker>
|
||||||
|
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||||
|
v-model="clientBatchEditModal.expiryTime">
|
||||||
|
</a-persian-datepicker>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="clientBatchEditModal.expiryTime || clientBatchEditModal.delayedStart === true">
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.client.renewDesc" }}</span>
|
||||||
|
</template>
|
||||||
|
{{ i18n "pages.client.renew" }}
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model.number="clientBatchEditModal.reset" :min="0" :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="app.tgBotEnable">
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.inbounds.telegramDesc" }}</span>
|
||||||
|
</template>
|
||||||
|
Telegram ChatID
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number :style="{ width: '50%' }" v-model.number="clientBatchEditModal.tgId" :min="0" :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "comment" }}'>
|
||||||
|
<a-input v-model.trim="clientBatchEditModal.comment" :placeholder='{{ printf "%q" (i18n "pages.client.batchKeep") }}'></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
<script>
|
||||||
|
const clientBatchEditModal = {
|
||||||
|
visible: false,
|
||||||
|
confirmLoading: false,
|
||||||
|
title: '',
|
||||||
|
okText: '',
|
||||||
|
confirm: null,
|
||||||
|
dbInbound: new DBInbound(),
|
||||||
|
inbound: new Inbound(),
|
||||||
|
selectedCount: 0,
|
||||||
|
enable: null,
|
||||||
|
security: undefined,
|
||||||
|
flow: undefined,
|
||||||
|
limitIp: undefined,
|
||||||
|
totalGB: undefined,
|
||||||
|
expiryTime: undefined,
|
||||||
|
delayedStart: null,
|
||||||
|
reset: undefined,
|
||||||
|
tgId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
ok() {
|
||||||
|
const updateData = {};
|
||||||
|
if (clientBatchEditModal.enable !== null) updateData.enable = clientBatchEditModal.enable;
|
||||||
|
if (clientBatchEditModal.security !== undefined) updateData.security = clientBatchEditModal.security;
|
||||||
|
if (clientBatchEditModal.flow !== undefined) updateData.flow = clientBatchEditModal.flow;
|
||||||
|
if (clientBatchEditModal.limitIp !== undefined && clientBatchEditModal.limitIp !== null) updateData.limitIp = clientBatchEditModal.limitIp;
|
||||||
|
if (clientBatchEditModal.totalGB !== undefined && clientBatchEditModal.totalGB !== null) {
|
||||||
|
updateData.totalGB = NumberFormatter.toFixed(clientBatchEditModal.totalGB * SizeFormatter.ONE_GB, 0);
|
||||||
|
}
|
||||||
|
if (clientBatchEditModal.delayedStart === true) {
|
||||||
|
if (clientBatchEditModalApp.delayedExpireDays > 0) {
|
||||||
|
updateData.expiryTime = -86400000 * clientBatchEditModalApp.delayedExpireDays;
|
||||||
|
}
|
||||||
|
} else if (clientBatchEditModal.delayedStart === false && clientBatchEditModal.expiryTime) {
|
||||||
|
updateData.expiryTime = new Date(clientBatchEditModal.expiryTime).getTime();
|
||||||
|
}
|
||||||
|
if (clientBatchEditModal.reset !== undefined && clientBatchEditModal.reset !== null) updateData.reset = clientBatchEditModal.reset;
|
||||||
|
if (clientBatchEditModal.tgId !== undefined && clientBatchEditModal.tgId !== null) updateData.tgId = clientBatchEditModal.tgId;
|
||||||
|
if (clientBatchEditModal.comment !== undefined && clientBatchEditModal.comment !== null) updateData.comment = clientBatchEditModal.comment;
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
clientBatchEditModalApp.$message.warning('{{ i18n "pages.client.batchEditNoFields" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectUtil.execute(clientBatchEditModal.confirm, updateData, clientBatchEditModal.dbInbound.id);
|
||||||
|
},
|
||||||
|
show({
|
||||||
|
title = '',
|
||||||
|
okText = '{{ i18n "sure" }}',
|
||||||
|
dbInbound = null,
|
||||||
|
selectedCount = 0,
|
||||||
|
confirm = () => { }
|
||||||
|
}) {
|
||||||
|
if (!dbInbound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.visible = true;
|
||||||
|
this.title = title;
|
||||||
|
this.okText = okText;
|
||||||
|
this.confirm = confirm;
|
||||||
|
this.selectedCount = selectedCount;
|
||||||
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
|
this.inbound = dbInbound.toInbound();
|
||||||
|
this.enable = null;
|
||||||
|
this.security = undefined;
|
||||||
|
this.flow = undefined;
|
||||||
|
this.limitIp = undefined;
|
||||||
|
this.totalGB = undefined;
|
||||||
|
this.expiryTime = undefined;
|
||||||
|
this.delayedStart = null;
|
||||||
|
this.reset = undefined;
|
||||||
|
this.tgId = undefined;
|
||||||
|
this.comment = undefined;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
clientBatchEditModal.visible = false;
|
||||||
|
clientBatchEditModal.loading(false);
|
||||||
|
},
|
||||||
|
loading(loading = true) {
|
||||||
|
clientBatchEditModal.confirmLoading = loading;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientBatchEditModalApp = new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
el: '#client-batch-edit-modal',
|
||||||
|
data: {
|
||||||
|
clientBatchEditModal,
|
||||||
|
get inbound() {
|
||||||
|
return this.clientBatchEditModal.inbound;
|
||||||
|
},
|
||||||
|
get delayedExpireDays() {
|
||||||
|
return this.clientBatchEditModal.expiryTime < 0 ? this.clientBatchEditModal.expiryTime / -86400000 : 0;
|
||||||
|
},
|
||||||
|
get datepicker() {
|
||||||
|
return app.datepicker;
|
||||||
|
},
|
||||||
|
set delayedExpireDays(days) {
|
||||||
|
this.clientBatchEditModal.expiryTime = -86400000 * days;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
@ -577,6 +577,246 @@ func (s *InboundService) DelInboundClientByEmailForUser(userID int, isAdmin bool
|
||||||
return s.DelInboundClientByEmail(inboundID, email)
|
return s.DelInboundClientByEmail(inboundID, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchUpdateInboundClients updates multiple clients in a single inbound with the same field changes.
|
||||||
|
// clientIDs is a list of protocol-specific client identifiers (UUID for VMess/VLESS, password for Trojan, email for SS).
|
||||||
|
// updateFields is a JSON string containing only the fields to be updated (e.g. {"flow":"xtls-rprx-vision","limitIp":5}).
|
||||||
|
// Fields that should NOT be batch-updated (email, id, subId, password) are ignored if present.
|
||||||
|
func (s *InboundService) BatchUpdateInboundClients(inboundID int, clientIDs []string, updateFields string) (bool, error) {
|
||||||
|
if err := ensureSharedWriteAllowed(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clientIDs) == 0 {
|
||||||
|
return false, common.NewError("no clients specified for batch update")
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(updateFields), &updates); err != nil {
|
||||||
|
return false, common.NewError("invalid update fields JSON:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove fields that must not be batch-updated
|
||||||
|
delete(updates, "email")
|
||||||
|
delete(updates, "id")
|
||||||
|
delete(updates, "subId")
|
||||||
|
delete(updates, "password")
|
||||||
|
delete(updates, "created_at")
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return false, common.NewError("no valid fields to update")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInbound, err := s.GetInbound(inboundID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceClients, ok := settings["clients"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return false, common.NewError("invalid clients format in inbound settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
nowTs := time.Now().Unix() * 1000
|
||||||
|
updatedCount := 0
|
||||||
|
var oldEmails []string
|
||||||
|
|
||||||
|
for index, client := range interfaceClients {
|
||||||
|
c, ok := client.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId := ""
|
||||||
|
switch oldInbound.Protocol {
|
||||||
|
case "trojan":
|
||||||
|
if pw, ok := c["password"].(string); ok {
|
||||||
|
clientId = pw
|
||||||
|
}
|
||||||
|
case "shadowsocks":
|
||||||
|
if em, ok := c["email"].(string); ok {
|
||||||
|
clientId = em
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if id, ok := c["id"].(string); ok {
|
||||||
|
clientId = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := false
|
||||||
|
for _, targetID := range clientIDs {
|
||||||
|
if clientId == targetID {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if email, ok := c["email"].(string); ok && email != "" {
|
||||||
|
oldEmails = append(oldEmails, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range updates {
|
||||||
|
c[key] = value
|
||||||
|
}
|
||||||
|
c["updated_at"] = nowTs
|
||||||
|
interfaceClients[index] = c
|
||||||
|
updatedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedCount == 0 {
|
||||||
|
return false, common.NewError("no matching clients found")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings["clients"] = interfaceClients
|
||||||
|
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInbound.Settings = string(newSettings)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
tx := db.Begin()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
} else {
|
||||||
|
tx.Commit()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var updatesMap map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(updateFields), &updatesMap); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasTotalGB := updatesMap["totalGB"]; hasTotalGB {
|
||||||
|
totalGBVal := toInt64(updatesMap["totalGB"])
|
||||||
|
for _, email := range oldEmails {
|
||||||
|
if err := tx.Model(&xray.ClientTraffic{}).Where("email = ? AND inbound_id = ?", email, inboundID).
|
||||||
|
Update("total", totalGBVal).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasExpiryTime := updatesMap["expiryTime"]; hasExpiryTime {
|
||||||
|
expiryTimeVal := toInt64(updatesMap["expiryTime"])
|
||||||
|
for _, email := range oldEmails {
|
||||||
|
if err := tx.Model(&xray.ClientTraffic{}).Where("email = ? AND inbound_id = ?", email, inboundID).
|
||||||
|
Update("expiry_time", expiryTimeVal).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasEnable := updatesMap["enable"]; hasEnable {
|
||||||
|
enableVal := updatesMap["enable"].(bool)
|
||||||
|
for _, email := range oldEmails {
|
||||||
|
if err := tx.Model(&xray.ClientTraffic{}).Where("email = ? AND inbound_id = ?", email, inboundID).
|
||||||
|
Update("enable", enableVal).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasReset := updatesMap["reset"]; hasReset {
|
||||||
|
resetVal := int(toInt64(updatesMap["reset"]))
|
||||||
|
for _, email := range oldEmails {
|
||||||
|
if err := tx.Model(&xray.ClientTraffic{}).Where("email = ? AND inbound_id = ?", email, inboundID).
|
||||||
|
Update("reset", resetVal).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasTgID := updatesMap["tgId"]; hasTgID {
|
||||||
|
tgIDVal := toInt64(updatesMap["tgId"])
|
||||||
|
for _, email := range oldEmails {
|
||||||
|
if err := tx.Model(&xray.ClientTraffic{}).Where("email = ? AND inbound_id = ?", email, inboundID).
|
||||||
|
Update("tg_id", tgIDVal).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Save(oldInbound).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
err = bumpSharedVersion(tx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
if _, hasEnable := updatesMap["enable"]; hasEnable {
|
||||||
|
s.xrayApi.Init(p.GetAPIPort())
|
||||||
|
enableVal := updatesMap["enable"].(bool)
|
||||||
|
for _, email := range oldEmails {
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client := make(map[string]any)
|
||||||
|
for i := range interfaceClients {
|
||||||
|
if c, ok := interfaceClients[i].(map[string]any); ok {
|
||||||
|
if c["email"] == email {
|
||||||
|
client = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(client) > 0 {
|
||||||
|
if !enableVal {
|
||||||
|
if err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email); err1 != nil {
|
||||||
|
if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
||||||
|
logger.Debug("Error in batch removing client by api:", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipher := ""
|
||||||
|
if oldInbound.Protocol == "shadowsocks" {
|
||||||
|
if m, ok := settings["method"].(string); ok {
|
||||||
|
cipher = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
|
||||||
|
"email": client["email"],
|
||||||
|
"id": client["id"],
|
||||||
|
"security": client["security"],
|
||||||
|
"flow": client["flow"],
|
||||||
|
"password": client["password"],
|
||||||
|
"cipher": cipher,
|
||||||
|
})
|
||||||
|
if err1 != nil {
|
||||||
|
logger.Debug("Error in batch adding client by api:", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.xrayApi.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) BatchUpdateInboundClientsForUser(userID int, isAdmin bool, inboundID int, clientIDs []string, updateFields string) (bool, error) {
|
||||||
|
if _, err := s.GetInboundForUser(userID, isAdmin, inboundID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return s.BatchUpdateInboundClients(inboundID, clientIDs, updateFields)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateInbound modifies an existing inbound configuration.
|
// UpdateInbound modifies an existing inbound configuration.
|
||||||
// It validates changes, updates the database, and syncs with the running Xray instance.
|
// It validates changes, updates the database, and syncs with the running Xray instance.
|
||||||
// Returns the updated inbound, whether Xray needs restart, and any error.
|
// Returns the updated inbound, whether Xray needs restart, and any error.
|
||||||
|
|
@ -2955,3 +3195,20 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
|
||||||
}
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toInt64 converts a JSON number (unmarshalled as float64) to int64.
|
||||||
|
func toInt64(v any) int64 {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(val)
|
||||||
|
case int64:
|
||||||
|
return val
|
||||||
|
case int:
|
||||||
|
return int64(val)
|
||||||
|
case json.Number:
|
||||||
|
n, _ := val.Int64()
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,14 @@
|
||||||
"submitEdit" = "Save Changes"
|
"submitEdit" = "Save Changes"
|
||||||
"clientCount" = "Number of Clients"
|
"clientCount" = "Number of Clients"
|
||||||
"bulk" = "Add Bulk"
|
"bulk" = "Add Bulk"
|
||||||
|
"batchEdit" = "Batch Edit"
|
||||||
|
"batchEditAlert" = "clients will be batch updated. Unique fields (email, ID, subscription) will not be modified."
|
||||||
|
"batchKeep" = "Keep unchanged"
|
||||||
|
"batchEditNoFields" = "No fields selected for update"
|
||||||
|
"batchDeselect" = "Deselect all"
|
||||||
|
"batchDeleteLastClient" = "Cannot delete all clients from an inbound"
|
||||||
|
|
||||||
|
"selected" = "Selected"
|
||||||
"method" = "Method"
|
"method" = "Method"
|
||||||
"first" = "First"
|
"first" = "First"
|
||||||
"last" = "Last"
|
"last" = "Last"
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,14 @@
|
||||||
"submitEdit" = "保存修改"
|
"submitEdit" = "保存修改"
|
||||||
"clientCount" = "客户端数量"
|
"clientCount" = "客户端数量"
|
||||||
"bulk" = "批量创建"
|
"bulk" = "批量创建"
|
||||||
|
"batchEdit" = "批量编辑"
|
||||||
|
"batchEditAlert" = "个客户端将被批量更新。唯一字段(邮箱、ID、订阅)不会被修改。"
|
||||||
|
"batchKeep" = "保持原样"
|
||||||
|
"batchEditNoFields" = "未选择要更新的字段"
|
||||||
|
"batchDeselect" = "取消选择"
|
||||||
|
"batchDeleteLastClient" = "不能删除入站中的全部客户端"
|
||||||
|
|
||||||
|
"selected" = "已选"
|
||||||
"method" = "方法"
|
"method" = "方法"
|
||||||
"first" = "置顶"
|
"first" = "置顶"
|
||||||
"last" = "置底"
|
"last" = "置底"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue