mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
feat(inbounds): bulk-attach & assign-group client actions + form defaults
- Bulk-attach an inbound's clients onto other inbounds (same identity, shared traffic): new ClientService.BulkAttach + POST /clients/bulkAttach, an inbound row action, and AttachClientsModal. - Assign all of an inbound's clients to a group from the inbound page, reusing /clients/bulkAssignGroup and the existing BulkAssignGroupModal. - Default a random user/pass account for new Mixed and HTTP inbounds instead of an empty accounts list. - Capitalize the inbound Security toggle labels (None/TLS/Reality).
This commit is contained in:
parent
9d9737f470
commit
1a096d72f1
10 changed files with 351 additions and 7 deletions
|
|
@ -185,13 +185,16 @@ export function createDefaultHysteriaInboundSettings(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultHttpInboundSettings(): HttpInboundSettings {
|
export function createDefaultHttpInboundSettings(): HttpInboundSettings {
|
||||||
return { accounts: [], allowTransparent: false };
|
return {
|
||||||
|
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
|
||||||
|
allowTransparent: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultMixedInboundSettings(): MixedInboundSettings {
|
export function createDefaultMixedInboundSettings(): MixedInboundSettings {
|
||||||
return {
|
return {
|
||||||
auth: 'password',
|
auth: 'password',
|
||||||
accounts: [],
|
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
|
||||||
udp: false,
|
udp: false,
|
||||||
ip: '127.0.0.1',
|
ip: '127.0.0.1',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
61
frontend/src/pages/inbounds/AssignClientsGroupModal.tsx
Normal file
61
frontend/src/pages/inbounds/AssignClientsGroupModal.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
|
|
||||||
|
const BulkAssignGroupModal = lazy(() => import('@/pages/clients/BulkAssignGroupModal'));
|
||||||
|
|
||||||
|
interface AssignClientsGroupModalProps {
|
||||||
|
open: boolean;
|
||||||
|
source: DBInbound | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onAssigned?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readClientEmails(settings: unknown): string[] {
|
||||||
|
const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
|
||||||
|
const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
|
||||||
|
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignClientsGroupModal({
|
||||||
|
open,
|
||||||
|
source,
|
||||||
|
onClose,
|
||||||
|
onAssigned,
|
||||||
|
}: AssignClientsGroupModalProps) {
|
||||||
|
const [groups, setGroups] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
|
||||||
|
if (cancelled) return;
|
||||||
|
const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
|
||||||
|
setGroups(list.map((g) => g?.name || '').filter(Boolean));
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BulkAssignGroupModal
|
||||||
|
open={open}
|
||||||
|
count={emails.length}
|
||||||
|
groups={groups}
|
||||||
|
onOpenChange={(o) => { if (!o) onClose(); }}
|
||||||
|
onSubmit={async (group) => {
|
||||||
|
const msg = await HttpUtil.post(
|
||||||
|
'/panel/api/clients/bulkAssignGroup',
|
||||||
|
{ emails, group },
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
if (!msg?.success) return null;
|
||||||
|
onAssigned?.();
|
||||||
|
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
frontend/src/pages/inbounds/AttachClientsModal.tsx
Normal file
108
frontend/src/pages/inbounds/AttachClientsModal.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
|
import { isInboundMultiUser } from './InboundList';
|
||||||
|
|
||||||
|
interface AttachClientsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
source: DBInbound | null;
|
||||||
|
dbInbounds: DBInbound[];
|
||||||
|
onClose: () => void;
|
||||||
|
onAttached?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkAttachResult {
|
||||||
|
attached?: string[];
|
||||||
|
skipped?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readClientEmails(settings: unknown): string[] {
|
||||||
|
const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
|
||||||
|
const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
|
||||||
|
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttachClientsModal({
|
||||||
|
open,
|
||||||
|
source,
|
||||||
|
dbInbounds,
|
||||||
|
onClose,
|
||||||
|
onAttached,
|
||||||
|
}: AttachClientsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [targetIds, setTargetIds] = useState<number[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setTargetIds([]);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
|
||||||
|
|
||||||
|
const targetOptions = useMemo(() => {
|
||||||
|
if (!source) return [];
|
||||||
|
return (dbInbounds || [])
|
||||||
|
.filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
|
||||||
|
.map((ib) => ({ value: ib.id, label: `${ib.remark} (${ib.protocol}@${ib.port})` }));
|
||||||
|
}, [dbInbounds, source]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!source || targetIds.length === 0 || emails.length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/clients/bulkAttach', { emails, inboundIds: targetIds }, { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!msg?.success) {
|
||||||
|
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = (msg.obj || {}) as BulkAttachResult;
|
||||||
|
const attached = result.attached?.length ?? 0;
|
||||||
|
const skipped = result.skipped?.length ?? 0;
|
||||||
|
const errors = result.errors?.length ?? 0;
|
||||||
|
if (errors > 0) {
|
||||||
|
messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
|
||||||
|
} else {
|
||||||
|
messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
|
||||||
|
}
|
||||||
|
onAttached?.();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={submit}
|
||||||
|
okButtonProps={{ disabled: targetIds.length === 0 || emails.length === 0, loading: saving }}
|
||||||
|
okText={t('pages.inbounds.attachClients')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark ?? '' })}
|
||||||
|
>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('pages.inbounds.attachClientsDesc', { count: emails.length })}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{targetOptions.length === 0 ? (
|
||||||
|
<Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={targetIds}
|
||||||
|
onChange={setTargetIds}
|
||||||
|
options={targetOptions}
|
||||||
|
placeholder={t('pages.inbounds.attachClientsTargets')}
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2473,9 +2473,9 @@ export default function InboundFormModal({
|
||||||
disabled={!tlsOk}
|
disabled={!tlsOk}
|
||||||
onChange={(e) => onSecurityChange(e.target.value)}
|
onChange={(e) => onSecurityChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{!tlsOnly && <Radio.Button value="none">none</Radio.Button>}
|
{!tlsOnly && <Radio.Button value="none">None</Radio.Button>}
|
||||||
<Radio.Button value="tls">tls</Radio.Button>
|
<Radio.Button value="tls">TLS</Radio.Button>
|
||||||
{realityOk && <Radio.Button value="reality">reality</Radio.Button>}
|
{realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import {
|
||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
UsergroupDeleteOutlined,
|
UsergroupDeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
|
@ -108,7 +110,7 @@ function readSettings(settings: unknown): { method?: string; network?: string; a
|
||||||
return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
|
return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
|
export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
|
||||||
switch (record.protocol) {
|
switch (record.protocol) {
|
||||||
case 'vmess':
|
case 'vmess':
|
||||||
case 'vless':
|
case 'vless':
|
||||||
|
|
@ -259,6 +261,8 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
|
||||||
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
|
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
|
||||||
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
|
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
|
||||||
if (isInboundMultiUser(record) && hasClients) {
|
if (isInboundMultiUser(record) && hasClients) {
|
||||||
|
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
|
||||||
|
items.push({ key: 'assignGroup', icon: <TagsOutlined />, label: t('pages.inbounds.assignClientsGroup') });
|
||||||
items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
|
items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
|
||||||
}
|
}
|
||||||
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
|
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import LazyMount from '@/components/LazyMount';
|
||||||
const InboundFormModal = lazy(() => import('./InboundFormModal'));
|
const InboundFormModal = lazy(() => import('./InboundFormModal'));
|
||||||
const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
|
const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
|
||||||
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
||||||
|
const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
|
||||||
|
const AssignClientsGroupModal = lazy(() => import('./AssignClientsGroupModal'));
|
||||||
|
|
||||||
type RowAction =
|
type RowAction =
|
||||||
| 'edit'
|
| 'edit'
|
||||||
|
|
@ -49,6 +51,8 @@ type RowAction =
|
||||||
| 'delete'
|
| 'delete'
|
||||||
| 'resetTraffic'
|
| 'resetTraffic'
|
||||||
| 'delAllClients'
|
| 'delAllClients'
|
||||||
|
| 'attachClients'
|
||||||
|
| 'assignGroup'
|
||||||
| 'clone';
|
| 'clone';
|
||||||
|
|
||||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||||
|
|
@ -121,6 +125,12 @@ export default function InboundsPage() {
|
||||||
const [qrOpen, setQrOpen] = useState(false);
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
|
const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
|
||||||
|
|
||||||
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
|
||||||
|
|
||||||
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
|
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
|
||||||
|
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
const [textOpen, setTextOpen] = useState(false);
|
||||||
const [textTitle, setTextTitle] = useState('');
|
const [textTitle, setTextTitle] = useState('');
|
||||||
const [textContent, setTextContent] = useState('');
|
const [textContent, setTextContent] = useState('');
|
||||||
|
|
@ -438,7 +448,7 @@ export default function InboundsPage() {
|
||||||
// Actions that touch per-client secrets (uuid, password, flow, ...) need
|
// Actions that touch per-client secrets (uuid, password, flow, ...) need
|
||||||
// the full payload that the slim list view does not ship. Hydrate first
|
// the full payload that the slim list view does not ship. Hydrate first
|
||||||
// and then operate on the rehydrated record.
|
// and then operate on the rehydrated record.
|
||||||
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone'];
|
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'assignGroup'];
|
||||||
let target = dbInbound;
|
let target = dbInbound;
|
||||||
if (hydratingKeys.includes(key)) {
|
if (hydratingKeys.includes(key)) {
|
||||||
const hydrated = await hydrateInbound(dbInbound.id);
|
const hydrated = await hydrateInbound(dbInbound.id);
|
||||||
|
|
@ -475,6 +485,14 @@ export default function InboundsPage() {
|
||||||
case 'delAllClients':
|
case 'delAllClients':
|
||||||
confirmDelAllClients(target);
|
confirmDelAllClients(target);
|
||||||
break;
|
break;
|
||||||
|
case 'attachClients':
|
||||||
|
setAttachSource(target);
|
||||||
|
setAttachOpen(true);
|
||||||
|
break;
|
||||||
|
case 'assignGroup':
|
||||||
|
setGroupSource(target);
|
||||||
|
setGroupOpen(true);
|
||||||
|
break;
|
||||||
case 'clone':
|
case 'clone':
|
||||||
confirmClone(target);
|
confirmClone(target);
|
||||||
break;
|
break;
|
||||||
|
|
@ -587,6 +605,23 @@ export default function InboundsPage() {
|
||||||
subSettings={subSettings}
|
subSettings={subSettings}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
<LazyMount when={attachOpen}>
|
||||||
|
<AttachClientsModal
|
||||||
|
open={attachOpen}
|
||||||
|
onClose={() => setAttachOpen(false)}
|
||||||
|
onAttached={refresh}
|
||||||
|
source={attachSource}
|
||||||
|
dbInbounds={dbInbounds}
|
||||||
|
/>
|
||||||
|
</LazyMount>
|
||||||
|
<LazyMount when={groupOpen}>
|
||||||
|
<AssignClientsGroupModal
|
||||||
|
open={groupOpen}
|
||||||
|
onClose={() => setGroupOpen(false)}
|
||||||
|
onAssigned={refresh}
|
||||||
|
source={groupSource}
|
||||||
|
/>
|
||||||
|
</LazyMount>
|
||||||
|
|
||||||
<LazyMount when={textOpen}>
|
<LazyMount when={textOpen}>
|
||||||
<TextModal
|
<TextModal
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/bulkDel", a.bulkDelete)
|
g.POST("/bulkDel", a.bulkDelete)
|
||||||
g.POST("/bulkCreate", a.bulkCreate)
|
g.POST("/bulkCreate", a.bulkCreate)
|
||||||
g.POST("/bulkAssignGroup", a.bulkAssignGroup)
|
g.POST("/bulkAssignGroup", a.bulkAssignGroup)
|
||||||
|
g.POST("/bulkAttach", a.bulkAttach)
|
||||||
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
||||||
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
||||||
g.POST("/ips/:email", a.getIps)
|
g.POST("/ips/:email", a.getIps)
|
||||||
|
|
@ -239,6 +240,29 @@ func (a *ClientController) bulkAssignGroup(c *gin.Context) {
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type bulkAttachRequest struct {
|
||||||
|
Emails []string `json:"emails"`
|
||||||
|
InboundIds []int `json:"inboundIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) bulkAttach(c *gin.Context) {
|
||||||
|
var req bulkAttachRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, needRestart, err := a.clientService.BulkAttach(&a.inboundService, req.Emails, req.InboundIds)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, result, nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ClientController) bulkDelete(c *gin.Context) {
|
func (a *ClientController) bulkDelete(c *gin.Context) {
|
||||||
var req bulkDeleteRequest
|
var req bulkDeleteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,99 @@ func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string,
|
||||||
return s.Attach(inboundSvc, rec.Id, inboundIds)
|
return s.Attach(inboundSvc, rec.Id, inboundIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkAttachResult reports the outcome of a bulk attach across target inbounds.
|
||||||
|
type BulkAttachResult struct {
|
||||||
|
Attached []string `json:"attached"`
|
||||||
|
Skipped []string `json:"skipped"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkAttach attaches the given existing clients (by email) to each target inbound,
|
||||||
|
// reusing their identity (email/UUID/password/subId) and a shared traffic row. It adds
|
||||||
|
// all clients to a target in a single AddInboundClient call, and reports clients already
|
||||||
|
// present on a target as skipped.
|
||||||
|
func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkAttachResult, bool, error) {
|
||||||
|
result := &BulkAttachResult{}
|
||||||
|
if len(emails) == 0 || len(inboundIds) == 0 {
|
||||||
|
return result, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]*model.ClientRecord, 0, len(emails))
|
||||||
|
seenEmail := make(map[string]struct{}, len(emails))
|
||||||
|
for _, email := range emails {
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(email)
|
||||||
|
if _, ok := seenEmail[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenEmail[key] = struct{}{}
|
||||||
|
rec, err := s.GetRecordByEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
for _, ibId := range inboundIds {
|
||||||
|
inbound, err := inboundSvc.GetInbound(ibId)
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingClients, err := inboundSvc.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
have := make(map[string]struct{}, len(existingClients))
|
||||||
|
for _, c := range existingClients {
|
||||||
|
have[strings.ToLower(c.Email)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientsToAdd := make([]model.Client, 0, len(records))
|
||||||
|
for _, rec := range records {
|
||||||
|
if _, attached := have[strings.ToLower(rec.Email)]; attached {
|
||||||
|
result.Skipped = append(result.Skipped, rec.Email)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client := *rec.ToClient()
|
||||||
|
client.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s -> inbound %d: %v", rec.Email, ibId, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clientsToAdd = append(clientsToAdd, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clientsToAdd) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string][]model.Client{"clients": clientsToAdd})
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nr, err := s.AddInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)})
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nr {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
for _, c := range clientsToAdd {
|
||||||
|
result.Attached = append(result.Attached, c.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return false, common.NewError("client email is required")
|
return false, common.NewError("client email is required")
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,14 @@
|
||||||
"delAllClients": "Delete All Clients",
|
"delAllClients": "Delete All Clients",
|
||||||
"delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?",
|
"delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?",
|
||||||
"delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.",
|
"delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.",
|
||||||
|
"attachClients": "Attach Clients To…",
|
||||||
|
"assignClientsGroup": "Assign Clients To Group…",
|
||||||
|
"attachClientsTitle": "Attach clients from \"{remark}\"",
|
||||||
|
"attachClientsDesc": "Attaches the same {count} clients (same UUID/password and shared traffic) to the selected inbound(s). They stay on this inbound too.",
|
||||||
|
"attachClientsTargets": "Target inbounds",
|
||||||
|
"attachClientsNoTargets": "No other compatible inbounds available to attach to.",
|
||||||
|
"attachClientsResult": "Attached {attached}, skipped {skipped}.",
|
||||||
|
"attachClientsResultMixed": "Attached {attached}, skipped {skipped}, errors {errors}.",
|
||||||
"exportLinksTitle": "Export inbound links",
|
"exportLinksTitle": "Export inbound links",
|
||||||
"exportSubsTitle": "Export subscription links",
|
"exportSubsTitle": "Export subscription links",
|
||||||
"exportAllLinksTitle": "Export all inbound links",
|
"exportAllLinksTitle": "Export all inbound links",
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,14 @@
|
||||||
"delAllClients": "حذف همه کلاینتها",
|
"delAllClients": "حذف همه کلاینتها",
|
||||||
"delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
|
"delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
|
||||||
"delAllClientsConfirmContent": "تمام کلاینتهای این اینباند به همراه رکوردهای ترافیکشان حذف میشوند. خود اینباند باقی میماند. این عمل غیرقابل بازگشت است.",
|
"delAllClientsConfirmContent": "تمام کلاینتهای این اینباند به همراه رکوردهای ترافیکشان حذف میشوند. خود اینباند باقی میماند. این عمل غیرقابل بازگشت است.",
|
||||||
|
"attachClients": "اتصال کلاینتها به…",
|
||||||
|
"assignClientsGroup": "افزودن کلاینتها به گروه…",
|
||||||
|
"attachClientsTitle": "اتصال کلاینتهای «{remark}»",
|
||||||
|
"attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخابشده هم متصل میکند. روی این اینباند هم باقی میمانند.",
|
||||||
|
"attachClientsTargets": "اینباندهای مقصد",
|
||||||
|
"attachClientsNoTargets": "اینباند سازگار دیگری برای اتصال وجود ندارد.",
|
||||||
|
"attachClientsResult": "{attached} متصل شد، {skipped} رد شد.",
|
||||||
|
"attachClientsResultMixed": "{attached} متصل شد، {skipped} رد شد، {errors} خطا.",
|
||||||
"exportLinksTitle": "خروجی لینکهای اینباند",
|
"exportLinksTitle": "خروجی لینکهای اینباند",
|
||||||
"exportSubsTitle": "خروجی لینکهای ساب",
|
"exportSubsTitle": "خروجی لینکهای ساب",
|
||||||
"exportAllLinksTitle": "خروجی لینکهای همه اینباندها",
|
"exportAllLinksTitle": "خروجی لینکهای همه اینباندها",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue