feat: add batch select and batch edit for inbound clients

This commit is contained in:
root 2026-04-26 17:11:58 +08:00
parent fb7beb4dd2
commit 18bdc2baa5
7 changed files with 694 additions and 1 deletions

View 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.

View file

@ -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)

View file

@ -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

View 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}}

View file

@ -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
}
}

View file

@ -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"

View file

@ -271,6 +271,14 @@
"submitEdit" = "保存修改" "submitEdit" = "保存修改"
"clientCount" = "客户端数量" "clientCount" = "客户端数量"
"bulk" = "批量创建" "bulk" = "批量创建"
"batchEdit" = "批量编辑"
"batchEditAlert" = "个客户端将被批量更新。唯一字段邮箱、ID、订阅不会被修改。"
"batchKeep" = "保持原样"
"batchEditNoFields" = "未选择要更新的字段"
"batchDeselect" = "取消选择"
"batchDeleteLastClient" = "不能删除入站中的全部客户端"
"selected" = "已选"
"method" = "方法" "method" = "方法"
"first" = "置顶" "first" = "置顶"
"last" = "置底" "last" = "置底"