浏览代码

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).
MHSanaei 4 小时之前
父节点
当前提交
1a096d72f1

+ 5 - 2
frontend/src/lib/xray/inbound-defaults.ts

@@ -185,13 +185,16 @@ export function createDefaultHysteriaInboundSettings(
 }
 
 export function createDefaultHttpInboundSettings(): HttpInboundSettings {
-  return { accounts: [], allowTransparent: false };
+  return {
+    accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
+    allowTransparent: false,
+  };
 }
 
 export function createDefaultMixedInboundSettings(): MixedInboundSettings {
   return {
     auth: 'password',
-    accounts: [],
+    accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
     udp: false,
     ip: '127.0.0.1',
   };

+ 61 - 0
frontend/src/pages/inbounds/AssignClientsGroupModal.tsx

@@ -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 - 0
frontend/src/pages/inbounds/AttachClientsModal.tsx

@@ -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>
+  );
+}

+ 3 - 3
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -2473,9 +2473,9 @@ export default function InboundFormModal({
                 disabled={!tlsOk}
                 onChange={(e) => onSecurityChange(e.target.value)}
               >
-                {!tlsOnly && <Radio.Button value="none">none</Radio.Button>}
-                <Radio.Button value="tls">tls</Radio.Button>
-                {realityOk && <Radio.Button value="reality">reality</Radio.Button>}
+                {!tlsOnly && <Radio.Button value="none">None</Radio.Button>}
+                <Radio.Button value="tls">TLS</Radio.Button>
+                {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
               </Radio.Group>
             );
           }}

+ 5 - 1
frontend/src/pages/inbounds/InboundList.tsx

@@ -28,6 +28,8 @@ import {
   BlockOutlined,
   DeleteOutlined,
   InfoCircleOutlined,
+  TagsOutlined,
+  UsergroupAddOutlined,
   UsergroupDeleteOutlined,
 } 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 };
 }
 
-function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
+export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
   switch (record.protocol) {
     case 'vmess':
     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: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
   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: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });

+ 36 - 1
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -38,6 +38,8 @@ import LazyMount from '@/components/LazyMount';
 const InboundFormModal = lazy(() => import('./InboundFormModal'));
 const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
 const QrCodeModal = lazy(() => import('./QrCodeModal'));
+const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
+const AssignClientsGroupModal = lazy(() => import('./AssignClientsGroupModal'));
 
 type RowAction =
   | 'edit'
@@ -49,6 +51,8 @@ type RowAction =
   | 'delete'
   | 'resetTraffic'
   | 'delAllClients'
+  | 'attachClients'
+  | 'assignGroup'
   | 'clone';
 
 type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -121,6 +125,12 @@ export default function InboundsPage() {
   const [qrOpen, setQrOpen] = useState(false);
   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 [textTitle, setTextTitle] = useState('');
   const [textContent, setTextContent] = useState('');
@@ -438,7 +448,7 @@ export default function InboundsPage() {
     // Actions that touch per-client secrets (uuid, password, flow, ...) need
     // the full payload that the slim list view does not ship. Hydrate first
     // 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;
     if (hydratingKeys.includes(key)) {
       const hydrated = await hydrateInbound(dbInbound.id);
@@ -475,6 +485,14 @@ export default function InboundsPage() {
       case 'delAllClients':
         confirmDelAllClients(target);
         break;
+      case 'attachClients':
+        setAttachSource(target);
+        setAttachOpen(true);
+        break;
+      case 'assignGroup':
+        setGroupSource(target);
+        setGroupOpen(true);
+        break;
       case 'clone':
         confirmClone(target);
         break;
@@ -587,6 +605,23 @@ export default function InboundsPage() {
             subSettings={subSettings}
           />
         </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}>
           <TextModal

+ 24 - 0
web/controller/client.go

@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/bulkDel", a.bulkDelete)
 	g.POST("/bulkCreate", a.bulkCreate)
 	g.POST("/bulkAssignGroup", a.bulkAssignGroup)
+	g.POST("/bulkAttach", a.bulkAttach)
 	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
@@ -239,6 +240,29 @@ func (a *ClientController) bulkAssignGroup(c *gin.Context) {
 	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) {
 	var req bulkDeleteRequest
 	if err := c.ShouldBindJSON(&req); err != nil {

+ 93 - 0
web/service/client.go

@@ -791,6 +791,99 @@ func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string,
 	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) {
 	if email == "" {
 		return false, common.NewError("client email is required")

+ 8 - 0
web/translation/en-US.json

@@ -298,6 +298,14 @@
       "delAllClients": "Delete All Clients",
       "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.",
+      "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",
       "exportSubsTitle": "Export subscription links",
       "exportAllLinksTitle": "Export all inbound links",

+ 8 - 0
web/translation/fa-IR.json

@@ -293,6 +293,14 @@
       "delAllClients": "حذف همه کلاینت‌ها",
       "delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
       "delAllClientsConfirmContent": "تمام کلاینت‌های این اینباند به همراه رکوردهای ترافیک‌شان حذف می‌شوند. خود اینباند باقی می‌ماند. این عمل غیرقابل بازگشت است.",
+      "attachClients": "اتصال کلاینت‌ها به…",
+      "assignClientsGroup": "افزودن کلاینت‌ها به گروه…",
+      "attachClientsTitle": "اتصال کلاینت‌های «{remark}»",
+      "attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخاب‌شده هم متصل می‌کند. روی این اینباند هم باقی می‌مانند.",
+      "attachClientsTargets": "اینباندهای مقصد",
+      "attachClientsNoTargets": "اینباند سازگار دیگری برای اتصال وجود ندارد.",
+      "attachClientsResult": "{attached} متصل شد، {skipped} رد شد.",
+      "attachClientsResultMixed": "{attached} متصل شد، {skipped} رد شد، {errors} خطا.",
       "exportLinksTitle": "خروجی لینک‌های اینباند",
       "exportSubsTitle": "خروجی لینک‌های ساب",
       "exportAllLinksTitle": "خروجی لینک‌های همه اینباندها",