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:
MHSanaei
2026-05-28 01:54:32 +02:00
parent 9d9737f470
commit 1a096d72f1
10 changed files with 351 additions and 7 deletions
+5 -2
View File
@@ -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',
};
@@ -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 };
}}
/>
);
}
@@ -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}
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
View File
@@ -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
View File
@@ -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