mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
refactor(clients): coherent group management — rename, split, extract
This bundles a set of group-related improvements that built up across one session and only make sense together. Terminology / API surface: - Rename "assign group" → "add to group" everywhere: i18n keys, callback names (bulkAddToGroup), component + file names (BulkAddToGroupModal, AddClientsToGroupModal), Go controller/struct names (bulkAddToGroup, AddToGroup), OpenAPI summaries. Nothing keeps the word "assign" anymore. - Move group routes under /panel/api/clients/groups/* (was /bulkAssignGroup at the clients root). - Split add and remove into two endpoints: /groups/bulkAdd now rejects empty group; new /groups/bulkRemove clears the label for the given emails. The old "submit empty to clear" UX is gone — Ungroup is its own action. UI affordances on Clients page: - Promote Group + Ungroup to visible bar buttons next to Attach + Detach. Group reuses BulkAddToGroupModal; Ungroup pops a danger confirm and calls bulkRemoveFromGroup. - Custom UngroupIcon (TagsOutlined with a diagonal strike) for the Ungroup button so the pairing reads at a glance. - Hide the Group column when no clients have a group label yet — removes a column of em-dashes on fresh installs. UI on Groups page: - New per-row Add clients… / Remove clients… actions backed by GroupAddClientsModal and GroupRemoveClientsModal: rich client picker (email / comment / current group / enable) with search and preserveSelectedRowKeys, mirroring the inbounds Attach modal UX. Controller split: - Move all /groups/* routes, handlers, and request bodies out of web/controller/client.go into a dedicated web/controller/group.go (GroupController with leaner clientService + xrayService dependencies). URLs are byte-identical because the new controller registers on the same parent gin.RouterGroup; api_docs_test.go gets a group.go → /panel/api/clients basePath entry so its route extraction keeps working. Invalidation dedup: - Removing a client from a group on the Groups page used to refetch /clients/groups and /clients/onlines three times: once from the mutation's onSuccess, once from a redundant invalidate() in the page's onSubmit, once from the WebSocket invalidate broadcast that the backend fires after every mutation. The manual invalidate() is gone, and a small invalidationTracker module lets websocketBridge skip WS-driven invalidates that arrive within 1.5s of a local invalidate — bringing the refetch count down to one. The WS path still works for changes made by another tab or user.
This commit is contained in:
parent
bf1b488a63
commit
530e338c66
20 changed files with 764 additions and 174 deletions
|
|
@ -2852,13 +2852,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/panel/api/clients/bulkAssignGroup": {
|
"/panel/api/clients/groups/bulkAdd": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Assign the given group label to many clients in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound's settings JSON in a single transaction. Pass an empty group to clear the label. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group.",
|
"summary": "Add many clients to a group in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound's settings JSON in a single transaction. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group. To clear the group label, use /groups/bulkRemove instead.",
|
||||||
"operationId": "post_panel_api_clients_bulkAssignGroup",
|
"operationId": "post_panel_api_clients_groups_bulkAdd",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -2905,6 +2905,58 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/groups/bulkRemove": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Clear the group label on many clients in one call. Inverse of /groups/bulkAdd. Clients themselves are kept — only the group label is cleared from clients.group_name and from each owning inbound's settings JSON. Groups become empty if all their members are removed.",
|
||||||
|
"operationId": "post_panel_api_clients_groups_bulkRemove",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"emails": [
|
||||||
|
"alice",
|
||||||
|
"bob"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"affected": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/bulkAttach": {
|
"/panel/api/clients/bulkAttach": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
@ -3178,7 +3230,7 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is assigned to it. Errors if a group with the same name already exists.",
|
"summary": "Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is added to it. Errors if a group with the same name already exists.",
|
||||||
"operationId": "post_panel_api_clients_groups_create",
|
"operationId": "post_panel_api_clients_groups_create",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|
|
||||||
9
frontend/src/api/invalidationTracker.ts
Normal file
9
frontend/src/api/invalidationTracker.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
let lastLocalInvalidateAt = 0;
|
||||||
|
|
||||||
|
export function markLocalInvalidate(): void {
|
||||||
|
lastLocalInvalidateAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecentLocalInvalidate(windowMs = 1500): boolean {
|
||||||
|
return Date.now() - lastLocalInvalidateAt < windowMs;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { WebSocketClient } from '@/api/websocket';
|
import { WebSocketClient } from '@/api/websocket';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
|
||||||
|
|
||||||
type Handler = (payload: unknown) => void;
|
type Handler = (payload: unknown) => void;
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ export function useWebSocketBridge() {
|
||||||
if (invalidateTimer != null) clearTimeout(invalidateTimer);
|
if (invalidateTimer != null) clearTimeout(invalidateTimer);
|
||||||
invalidateTimer = window.setTimeout(() => {
|
invalidateTimer = window.setTimeout(() => {
|
||||||
invalidateTimer = null;
|
invalidateTimer = null;
|
||||||
|
if (isRecentLocalInvalidate()) return;
|
||||||
if (p.type === 'inbounds') {
|
if (p.type === 'inbounds') {
|
||||||
queryClient.invalidateQueries({ queryKey: ['inbounds'] });
|
queryClient.invalidateQueries({ queryKey: ['inbounds'] });
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { markLocalInvalidate } from '@/api/invalidationTracker';
|
||||||
import {
|
import {
|
||||||
ClientHydrateSchema,
|
ClientHydrateSchema,
|
||||||
ClientPageResponseSchema,
|
ClientPageResponseSchema,
|
||||||
|
|
@ -213,10 +214,13 @@ export function useClients() {
|
||||||
// Inbounds page and any open edit modal pick up the new shape without
|
// Inbounds page and any open edit modal pick up the new shape without
|
||||||
// a manual reload.
|
// a manual reload.
|
||||||
const invalidateAll = useCallback(
|
const invalidateAll = useCallback(
|
||||||
() => Promise.all([
|
() => {
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
markLocalInvalidate();
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
return Promise.all([
|
||||||
]),
|
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -238,9 +242,15 @@ export function useClients() {
|
||||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
const bulkAssignGroupMut = useMutation({
|
const bulkAddToGroupMut = useMutation({
|
||||||
mutationFn: (body: { emails: string[]; group: string }) =>
|
mutationFn: (body: { emails: string[]; group: string }) =>
|
||||||
HttpUtil.post('/panel/api/clients/bulkAssignGroup', body, JSON_HEADERS),
|
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkRemoveFromGroupMut = useMutation({
|
||||||
|
mutationFn: (body: { emails: string[] }) =>
|
||||||
|
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
|
||||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -352,10 +362,14 @@ export function useClients() {
|
||||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||||
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
|
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
|
||||||
}, [bulkAdjustMut]);
|
}, [bulkAdjustMut]);
|
||||||
const bulkAssignGroup = useCallback((emails: string[], group: string) => {
|
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
|
||||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||||
return bulkAssignGroupMut.mutateAsync({ emails, group });
|
return bulkAddToGroupMut.mutateAsync({ emails, group });
|
||||||
}, [bulkAssignGroupMut]);
|
}, [bulkAddToGroupMut]);
|
||||||
|
const bulkRemoveFromGroup = useCallback((emails: string[]) => {
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||||
|
return bulkRemoveFromGroupMut.mutateAsync({ emails });
|
||||||
|
}, [bulkRemoveFromGroupMut]);
|
||||||
const attach = useCallback((email: string, inboundIds: number[]) => {
|
const attach = useCallback((email: string, inboundIds: number[]) => {
|
||||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||||
return attachMut.mutateAsync({ email, inboundIds });
|
return attachMut.mutateAsync({ email, inboundIds });
|
||||||
|
|
@ -472,7 +486,8 @@ export function useClients() {
|
||||||
remove,
|
remove,
|
||||||
bulkDelete,
|
bulkDelete,
|
||||||
bulkAdjust,
|
bulkAdjust,
|
||||||
bulkAssignGroup,
|
bulkAddToGroup,
|
||||||
|
bulkRemoveFromGroup,
|
||||||
attach,
|
attach,
|
||||||
bulkAttach,
|
bulkAttach,
|
||||||
detach,
|
detach,
|
||||||
|
|
|
||||||
|
|
@ -546,11 +546,18 @@ export const sections: readonly Section[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/bulkAssignGroup',
|
path: '/panel/api/clients/groups/bulkAdd',
|
||||||
summary: 'Assign the given group label to many clients in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound\'s settings JSON in a single transaction. Pass an empty group to clear the label. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group.',
|
summary: 'Add many clients to a group in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound\'s settings JSON in a single transaction. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group. To clear the group label, use /groups/bulkRemove instead.',
|
||||||
body: '{\n "emails": ["alice", "bob"],\n "group": "customer-a"\n}',
|
body: '{\n "emails": ["alice", "bob"],\n "group": "customer-a"\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/groups/bulkRemove',
|
||||||
|
summary: 'Clear the group label on many clients in one call. Inverse of /groups/bulkAdd. Clients themselves are kept — only the group label is cleared from clients.group_name and from each owning inbound\'s settings JSON. Groups become empty if all their members are removed.',
|
||||||
|
body: '{\n "emails": ["alice", "bob"]\n}',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/bulkAttach',
|
path: '/panel/api/clients/bulkAttach',
|
||||||
|
|
@ -598,7 +605,7 @@ export const sections: readonly Section[] = [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/groups/create',
|
path: '/panel/api/clients/groups/create',
|
||||||
summary: 'Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is assigned to it. Errors if a group with the same name already exists.',
|
summary: 'Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is added to it. Errors if a group with the same name already exists.',
|
||||||
body: '{\n "name": "customer-a"\n}',
|
body: '{\n "name": "customer-a"\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "name": "customer-a"\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "name": "customer-a"\n }\n}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AutoComplete, Form, Modal, message } from 'antd';
|
import { AutoComplete, Form, Modal, message } from 'antd';
|
||||||
|
|
||||||
interface BulkAssignGroupModalProps {
|
interface BulkAddToGroupModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
|
@ -10,13 +10,13 @@ interface BulkAssignGroupModalProps {
|
||||||
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
|
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BulkAssignGroupModal({
|
export default function BulkAddToGroupModal({
|
||||||
open,
|
open,
|
||||||
count,
|
count,
|
||||||
groups,
|
groups,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: BulkAssignGroupModalProps) {
|
}: BulkAddToGroupModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
|
|
@ -28,16 +28,13 @@ export default function BulkAssignGroupModal({
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const next = value.trim();
|
const next = value.trim();
|
||||||
|
if (!next) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await onSubmit(next);
|
const result = await onSubmit(next);
|
||||||
if (result) {
|
if (result) {
|
||||||
const affected = result.affected ?? 0;
|
const affected = result.affected ?? 0;
|
||||||
if (next === '') {
|
messageApi.success(t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }));
|
||||||
messageApi.success(t('pages.clients.assignGroupClearedToast', { count: affected }));
|
|
||||||
} else {
|
|
||||||
messageApi.success(t('pages.clients.assignGroupAssignedToast', { count: affected, group: next }));
|
|
||||||
}
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -50,10 +47,11 @@ export default function BulkAssignGroupModal({
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={t('pages.clients.assignGroupTitle', { count })}
|
title={t('pages.clients.addToGroupTitle', { count })}
|
||||||
okText={t('save')}
|
okText={t('add')}
|
||||||
cancelText={t('cancel')}
|
cancelText={t('cancel')}
|
||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
|
okButtonProps={{ disabled: !value.trim() }}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
onOk={submit}
|
onOk={submit}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
|
|
@ -61,11 +59,11 @@ export default function BulkAssignGroupModal({
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.clients.group')}
|
label={t('pages.clients.group')}
|
||||||
tooltip={t('pages.clients.assignGroupTooltip')}
|
tooltip={t('pages.clients.addToGroupTooltip')}
|
||||||
>
|
>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={t('pages.clients.assignGroupPlaceholder')}
|
placeholder={t('pages.clients.addToGroupPlaceholder')}
|
||||||
options={groups.map((g) => ({ value: g }))}
|
options={groups.map((g) => ({ value: g }))}
|
||||||
onChange={(v) => setValue(v ?? '')}
|
onChange={(v) => setValue(v ?? '')}
|
||||||
filterOption={(input, option) =>
|
filterOption={(input, option) =>
|
||||||
|
|
@ -62,7 +62,7 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
||||||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||||||
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
||||||
const SubLinksModal = lazy(() => import('./SubLinksModal'));
|
const SubLinksModal = lazy(() => import('./SubLinksModal'));
|
||||||
const BulkAssignGroupModal = lazy(() => import('./BulkAssignGroupModal'));
|
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
|
||||||
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
||||||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||||||
import { emptyFilters, activeFilterCount } from './filters';
|
import { emptyFilters, activeFilterCount } from './filters';
|
||||||
|
|
@ -71,6 +71,45 @@ import './ClientsPage.css';
|
||||||
|
|
||||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||||
|
|
||||||
|
function UngroupIcon() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TagsOutlined />
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '125%',
|
||||||
|
height: '1.5px',
|
||||||
|
background: 'currentColor',
|
||||||
|
transform: 'rotate(-45deg)',
|
||||||
|
borderRadius: '1px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface PersistedFilterState {
|
interface PersistedFilterState {
|
||||||
|
|
@ -152,7 +191,7 @@ export default function ClientsPage() {
|
||||||
setQuery,
|
setQuery,
|
||||||
inbounds, onlines, loading, fetched, subSettings,
|
inbounds, onlines, loading, fetched, subSettings,
|
||||||
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkAssignGroup, attach, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||||||
applyTrafficEvent, applyClientStatsEvent,
|
applyTrafficEvent, applyClientStatsEvent,
|
||||||
hydrate,
|
hydrate,
|
||||||
|
|
@ -461,6 +500,26 @@ export default function ClientsPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onBulkUngroup() {
|
||||||
|
const emails = [...selectedRowKeys];
|
||||||
|
if (emails.length === 0) return;
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
|
||||||
|
content: t('pages.clients.ungroupConfirmContent'),
|
||||||
|
okText: t('confirm'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await bulkRemoveFromGroup(emails);
|
||||||
|
if (msg?.success) {
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
|
||||||
|
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onBulkDelete() {
|
function onBulkDelete() {
|
||||||
const emails = [...selectedRowKeys];
|
const emails = [...selectedRowKeys];
|
||||||
if (emails.length === 0) return;
|
if (emails.length === 0) return;
|
||||||
|
|
@ -586,6 +645,7 @@ export default function ClientsPage() {
|
||||||
title: t('pages.clients.group'),
|
title: t('pages.clients.group'),
|
||||||
key: 'group',
|
key: 'group',
|
||||||
width: 130,
|
width: 130,
|
||||||
|
hidden: allGroups.length === 0,
|
||||||
render: (_v, record) => {
|
render: (_v, record) => {
|
||||||
if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||||||
const isActive = filters.groups.includes(record.group);
|
const isActive = filters.groups.includes(record.group);
|
||||||
|
|
@ -670,7 +730,7 @@ export default function ClientsPage() {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters]);
|
], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups]);
|
||||||
|
|
||||||
const tablePagination = {
|
const tablePagination = {
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
|
|
@ -803,6 +863,12 @@ export default function ClientsPage() {
|
||||||
<Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
|
<Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
|
||||||
{!isMobile && t('pages.clients.detach')}
|
{!isMobile && t('pages.clients.detach')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
|
||||||
|
{!isMobile && t('pages.clients.addToGroup')}
|
||||||
|
</Button>
|
||||||
|
<Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
|
||||||
|
{!isMobile && t('pages.clients.ungroup')}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|
@ -817,12 +883,6 @@ export default function ClientsPage() {
|
||||||
label: t('pages.clients.adjust'),
|
label: t('pages.clients.adjust'),
|
||||||
onClick: () => setBulkAdjustOpen(true),
|
onClick: () => setBulkAdjustOpen(true),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'group',
|
|
||||||
icon: <TagsOutlined />,
|
|
||||||
label: t('pages.clients.group'),
|
|
||||||
onClick: () => setBulkGroupOpen(true),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'subLinks',
|
key: 'subLinks',
|
||||||
icon: <LinkOutlined />,
|
icon: <LinkOutlined />,
|
||||||
|
|
@ -1181,13 +1241,13 @@ export default function ClientsPage() {
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={bulkGroupOpen}>
|
<LazyMount when={bulkGroupOpen}>
|
||||||
<BulkAssignGroupModal
|
<BulkAddToGroupModal
|
||||||
open={bulkGroupOpen}
|
open={bulkGroupOpen}
|
||||||
count={selectedRowKeys.length}
|
count={selectedRowKeys.length}
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
onOpenChange={setBulkGroupOpen}
|
onOpenChange={setBulkGroupOpen}
|
||||||
onSubmit={async (group) => {
|
onSubmit={async (group) => {
|
||||||
const msg = await bulkAssignGroup([...selectedRowKeys], group);
|
const msg = await bulkAddToGroup([...selectedRowKeys], group);
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||||
|
|
|
||||||
161
frontend/src/pages/groups/GroupAddClientsModal.tsx
Normal file
161
frontend/src/pages/groups/GroupAddClientsModal.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
|
import type { ClientRecord } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
interface GroupAddClientsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
groupName: string | null;
|
||||||
|
candidates: ClientRecord[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientRow {
|
||||||
|
email: string;
|
||||||
|
comment: string;
|
||||||
|
enable: boolean;
|
||||||
|
currentGroup: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupAddClientsModal({
|
||||||
|
open,
|
||||||
|
groupName,
|
||||||
|
candidates,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: GroupAddClientsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const rows = useMemo<ClientRow[]>(
|
||||||
|
() =>
|
||||||
|
(candidates || [])
|
||||||
|
.map((c) => ({
|
||||||
|
email: (c.email || '').trim(),
|
||||||
|
comment: (c.comment || '').trim(),
|
||||||
|
enable: c.enable !== false,
|
||||||
|
currentGroup: (c.group || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((r) => r.email),
|
||||||
|
[candidates],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSelectedEmails([]);
|
||||||
|
setSearch('');
|
||||||
|
}, [open, rows]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return rows;
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.email.toLowerCase().includes(q) ||
|
||||||
|
r.comment.toLowerCase().includes(q) ||
|
||||||
|
r.currentGroup.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [rows, search]);
|
||||||
|
|
||||||
|
const columns: ColumnsType<ClientRow> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
|
||||||
|
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: t('pages.clients.group'),
|
||||||
|
dataIndex: 'currentGroup',
|
||||||
|
key: 'currentGroup',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (g: string) =>
|
||||||
|
g ? <Tag color="geekblue">{g}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('enable'),
|
||||||
|
dataIndex: 'enable',
|
||||||
|
key: 'enable',
|
||||||
|
width: 90,
|
||||||
|
render: (enabled: boolean) =>
|
||||||
|
enabled ? (
|
||||||
|
<Tag color="success">{t('enable')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!groupName || selectedEmails.length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await onSubmit(selectedEmails);
|
||||||
|
if (!result) return;
|
||||||
|
const affected = result.affected ?? selectedEmails.length;
|
||||||
|
messageApi.success(t('pages.groups.addToGroupResult', { count: affected, name: groupName }));
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={submit}
|
||||||
|
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
|
||||||
|
okText={t('add')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
title={t('pages.groups.addToGroupTitle', { name: groupName ?? '' })}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('pages.groups.addToGroupDesc')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||||
|
style={{ maxWidth: 320 }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.inbounds.attachClientsSelectedCount', {
|
||||||
|
selected: selectedEmails.length,
|
||||||
|
total: rows.length,
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Alert type="info" showIcon message={t('pages.groups.addToGroupEmpty')} />
|
||||||
|
) : (
|
||||||
|
<Table<ClientRow>
|
||||||
|
size="small"
|
||||||
|
rowKey="email"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredRows}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 320 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedEmails,
|
||||||
|
onChange: (keys) => setSelectedEmails(keys as string[]),
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/pages/groups/GroupRemoveClientsModal.tsx
Normal file
145
frontend/src/pages/groups/GroupRemoveClientsModal.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
|
import type { ClientRecord } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
interface GroupRemoveClientsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
groupName: string | null;
|
||||||
|
members: ClientRecord[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientRow {
|
||||||
|
email: string;
|
||||||
|
comment: string;
|
||||||
|
enable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupRemoveClientsModal({
|
||||||
|
open,
|
||||||
|
groupName,
|
||||||
|
members,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: GroupRemoveClientsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const rows = useMemo<ClientRow[]>(
|
||||||
|
() =>
|
||||||
|
(members || [])
|
||||||
|
.map((c) => ({
|
||||||
|
email: (c.email || '').trim(),
|
||||||
|
comment: (c.comment || '').trim(),
|
||||||
|
enable: c.enable !== false,
|
||||||
|
}))
|
||||||
|
.filter((r) => r.email),
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSelectedEmails([]);
|
||||||
|
setSearch('');
|
||||||
|
}, [open, rows]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return rows;
|
||||||
|
return rows.filter(
|
||||||
|
(r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [rows, search]);
|
||||||
|
|
||||||
|
const columns: ColumnsType<ClientRow> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
|
||||||
|
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: t('enable'),
|
||||||
|
dataIndex: 'enable',
|
||||||
|
key: 'enable',
|
||||||
|
width: 90,
|
||||||
|
render: (enabled: boolean) =>
|
||||||
|
enabled ? (
|
||||||
|
<Tag color="success">{t('enable')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!groupName || selectedEmails.length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await onSubmit(selectedEmails);
|
||||||
|
if (!result) return;
|
||||||
|
const affected = result.affected ?? selectedEmails.length;
|
||||||
|
messageApi.success(
|
||||||
|
t('pages.groups.removeFromGroupResult', { count: affected, name: groupName }),
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={submit}
|
||||||
|
okButtonProps={{ danger: true, disabled: selectedEmails.length === 0, loading: saving }}
|
||||||
|
okText={t('remove')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
title={t('pages.groups.removeFromGroupTitle', { name: groupName ?? '' })}
|
||||||
|
width={680}
|
||||||
|
>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('pages.groups.removeFromGroupDesc')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||||
|
style={{ maxWidth: 320 }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.inbounds.attachClientsSelectedCount', {
|
||||||
|
selected: selectedEmails.length,
|
||||||
|
total: rows.length,
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Table<ClientRow>
|
||||||
|
size="small"
|
||||||
|
rowKey="email"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredRows}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 280 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedEmails,
|
||||||
|
onChange: (keys) => setSelectedEmails(keys as string[]),
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,8 @@ import {
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
|
UsergroupDeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
|
@ -47,6 +49,8 @@ import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
|
||||||
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
|
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
|
||||||
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
|
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
|
||||||
|
const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
|
||||||
|
const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
|
||||||
|
|
||||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||||
|
|
||||||
|
|
@ -77,7 +81,7 @@ export default function GroupsPage() {
|
||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { clients, subSettings, bulkAdjust, bulkDelete } = useClients();
|
const { clients, subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
|
||||||
|
|
||||||
const groupsQuery = useQuery({
|
const groupsQuery = useQuery({
|
||||||
queryKey: keys.clients.groups(),
|
queryKey: keys.clients.groups(),
|
||||||
|
|
@ -124,6 +128,8 @@ export default function GroupsPage() {
|
||||||
|
|
||||||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||||||
const [adjustOpen, setAdjustOpen] = useState(false);
|
const [adjustOpen, setAdjustOpen] = useState(false);
|
||||||
|
const [addClientsOpen, setAddClientsOpen] = useState(false);
|
||||||
|
const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
|
||||||
const [groupEmails, setGroupEmails] = useState<string[]>([]);
|
const [groupEmails, setGroupEmails] = useState<string[]>([]);
|
||||||
const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
|
const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
|
||||||
|
|
||||||
|
|
@ -228,6 +234,20 @@ export default function GroupsPage() {
|
||||||
setAdjustOpen(true);
|
setAdjustOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddClientsFor(g: GroupSummary) {
|
||||||
|
setGroupForAction(g);
|
||||||
|
setAddClientsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRemoveClientsFor(g: GroupSummary) {
|
||||||
|
if (!g.clientCount) {
|
||||||
|
messageApi.info(t('pages.groups.emptyForAction'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGroupForAction(g);
|
||||||
|
setRemoveClientsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
function onDeleteClients(g: GroupSummary) {
|
function onDeleteClients(g: GroupSummary) {
|
||||||
if (!g.clientCount) {
|
if (!g.clientCount) {
|
||||||
messageApi.info(t('pages.groups.emptyForAction'));
|
messageApi.info(t('pages.groups.emptyForAction'));
|
||||||
|
|
@ -306,6 +326,20 @@ export default function GroupsPage() {
|
||||||
disabled: !row.clientCount,
|
disabled: !row.clientCount,
|
||||||
onClick: () => onResetTraffic(row),
|
onClick: () => onResetTraffic(row),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'addClients',
|
||||||
|
icon: <UsergroupAddOutlined />,
|
||||||
|
label: t('pages.groups.addToGroup'),
|
||||||
|
onClick: () => openAddClientsFor(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'removeClients',
|
||||||
|
icon: <UsergroupDeleteOutlined />,
|
||||||
|
label: t('pages.groups.removeFromGroup'),
|
||||||
|
danger: true,
|
||||||
|
disabled: !row.clientCount,
|
||||||
|
onClick: () => openRemoveClientsFor(row),
|
||||||
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
|
|
@ -522,6 +556,38 @@ export default function GroupsPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
|
||||||
|
<LazyMount when={addClientsOpen}>
|
||||||
|
<GroupAddClientsModal
|
||||||
|
open={addClientsOpen}
|
||||||
|
groupName={groupForAction?.name ?? null}
|
||||||
|
candidates={clients.filter((c) => c.group !== groupForAction?.name)}
|
||||||
|
onClose={() => setAddClientsOpen(false)}
|
||||||
|
onSubmit={async (emails) => {
|
||||||
|
const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
|
||||||
|
if (msg?.success) {
|
||||||
|
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LazyMount>
|
||||||
|
|
||||||
|
<LazyMount when={removeClientsOpen}>
|
||||||
|
<GroupRemoveClientsModal
|
||||||
|
open={removeClientsOpen}
|
||||||
|
groupName={groupForAction?.name ?? null}
|
||||||
|
members={clients.filter((c) => c.group === groupForAction?.name)}
|
||||||
|
onClose={() => setRemoveClientsOpen(false)}
|
||||||
|
onSubmit={async (emails) => {
|
||||||
|
const msg = await bulkRemoveFromGroup(emails);
|
||||||
|
if (msg?.success) {
|
||||||
|
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LazyMount>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
|
|
||||||
const BulkAssignGroupModal = lazy(() => import('@/pages/clients/BulkAssignGroupModal'));
|
const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
|
||||||
|
|
||||||
interface AssignClientsGroupModalProps {
|
interface AddClientsToGroupModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
source: DBInbound | null;
|
source: DBInbound | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAssigned?: () => void;
|
onAdded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readClientEmails(settings: unknown): string[] {
|
function readClientEmails(settings: unknown): string[] {
|
||||||
|
|
@ -18,12 +18,12 @@ function readClientEmails(settings: unknown): string[] {
|
||||||
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
|
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignClientsGroupModal({
|
export default function AddClientsToGroupModal({
|
||||||
open,
|
open,
|
||||||
source,
|
source,
|
||||||
onClose,
|
onClose,
|
||||||
onAssigned,
|
onAdded,
|
||||||
}: AssignClientsGroupModalProps) {
|
}: AddClientsToGroupModalProps) {
|
||||||
const [groups, setGroups] = useState<string[]>([]);
|
const [groups, setGroups] = useState<string[]>([]);
|
||||||
|
|
||||||
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
|
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
|
||||||
|
|
@ -41,19 +41,19 @@ export default function AssignClientsGroupModal({
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BulkAssignGroupModal
|
<BulkAddToGroupModal
|
||||||
open={open}
|
open={open}
|
||||||
count={emails.length}
|
count={emails.length}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
onOpenChange={(o) => { if (!o) onClose(); }}
|
onOpenChange={(o) => { if (!o) onClose(); }}
|
||||||
onSubmit={async (group) => {
|
onSubmit={async (group) => {
|
||||||
const msg = await HttpUtil.post(
|
const msg = await HttpUtil.post(
|
||||||
'/panel/api/clients/bulkAssignGroup',
|
'/panel/api/clients/groups/bulkAdd',
|
||||||
{ emails, group },
|
{ emails, group },
|
||||||
{ headers: { 'Content-Type': 'application/json' } },
|
{ headers: { 'Content-Type': 'application/json' } },
|
||||||
);
|
);
|
||||||
if (!msg?.success) return null;
|
if (!msg?.success) return null;
|
||||||
onAssigned?.();
|
onAdded?.();
|
||||||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -263,7 +263,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
|
||||||
if (isInboundMultiUser(record) && hasClients) {
|
if (isInboundMultiUser(record) && hasClients) {
|
||||||
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
|
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
|
||||||
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
|
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
|
||||||
items.push({ key: 'assignGroup', icon: <TagsOutlined />, label: t('pages.inbounds.assignClientsGroup') });
|
items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
|
||||||
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') });
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
|
||||||
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
||||||
const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
|
const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
|
||||||
const DetachClientsModal = lazy(() => import('./DetachClientsModal'));
|
const DetachClientsModal = lazy(() => import('./DetachClientsModal'));
|
||||||
const AssignClientsGroupModal = lazy(() => import('./AssignClientsGroupModal'));
|
const AddClientsToGroupModal = lazy(() => import('./AddClientsToGroupModal'));
|
||||||
|
|
||||||
type RowAction =
|
type RowAction =
|
||||||
| 'edit'
|
| 'edit'
|
||||||
|
|
@ -54,7 +54,7 @@ type RowAction =
|
||||||
| 'delAllClients'
|
| 'delAllClients'
|
||||||
| 'attachClients'
|
| 'attachClients'
|
||||||
| 'detachClients'
|
| 'detachClients'
|
||||||
| 'assignGroup'
|
| 'addToGroup'
|
||||||
| 'clone';
|
| 'clone';
|
||||||
|
|
||||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||||
|
|
@ -452,7 +452,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', 'attachClients', 'assignGroup'];
|
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'addToGroup'];
|
||||||
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);
|
||||||
|
|
@ -497,7 +497,7 @@ export default function InboundsPage() {
|
||||||
setDetachSource(target);
|
setDetachSource(target);
|
||||||
setDetachOpen(true);
|
setDetachOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'assignGroup':
|
case 'addToGroup':
|
||||||
setGroupSource(target);
|
setGroupSource(target);
|
||||||
setGroupOpen(true);
|
setGroupOpen(true);
|
||||||
break;
|
break;
|
||||||
|
|
@ -631,10 +631,10 @@ export default function InboundsPage() {
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={groupOpen}>
|
<LazyMount when={groupOpen}>
|
||||||
<AssignClientsGroupModal
|
<AddClientsToGroupModal
|
||||||
open={groupOpen}
|
open={groupOpen}
|
||||||
onClose={() => setGroupOpen(false)}
|
onClose={() => setGroupOpen(false)}
|
||||||
onAssigned={refresh}
|
onAdded={refresh}
|
||||||
source={groupSource}
|
source={groupSource}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
||||||
|
|
||||||
clients := api.Group("/clients")
|
clients := api.Group("/clients")
|
||||||
NewClientController(clients)
|
NewClientController(clients)
|
||||||
|
NewGroupController(clients)
|
||||||
|
|
||||||
// Server API
|
// Server API
|
||||||
server := api.Group("/server")
|
server := api.Group("/server")
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
basePath = "/panel/api/inbounds"
|
basePath = "/panel/api/inbounds"
|
||||||
case "client.go":
|
case "client.go":
|
||||||
basePath = "/panel/api/clients"
|
basePath = "/panel/api/clients"
|
||||||
|
case "group.go":
|
||||||
|
basePath = "/panel/api/clients"
|
||||||
case "server.go":
|
case "server.go":
|
||||||
basePath = "/panel/api/server"
|
basePath = "/panel/api/server"
|
||||||
case "node.go":
|
case "node.go":
|
||||||
|
|
|
||||||
|
|
@ -47,22 +47,15 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/bulkAdjust", a.bulkAdjust)
|
g.POST("/bulkAdjust", a.bulkAdjust)
|
||||||
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("/bulkAttach", a.bulkAttach)
|
g.POST("/bulkAttach", a.bulkAttach)
|
||||||
g.POST("/bulkDetach", a.bulkDetach)
|
g.POST("/bulkDetach", a.bulkDetach)
|
||||||
|
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
|
||||||
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)
|
||||||
g.POST("/clearIps/:email", a.clearIps)
|
g.POST("/clearIps/:email", a.clearIps)
|
||||||
g.POST("/onlines", a.onlines)
|
g.POST("/onlines", a.onlines)
|
||||||
g.POST("/lastOnline", a.lastOnline)
|
g.POST("/lastOnline", a.lastOnline)
|
||||||
|
|
||||||
g.GET("/groups", a.listGroups)
|
|
||||||
g.GET("/groups/:name/emails", a.groupEmails)
|
|
||||||
g.POST("/groups/create", a.createGroup)
|
|
||||||
g.POST("/groups/rename", a.renameGroup)
|
|
||||||
g.POST("/groups/delete", a.deleteGroup)
|
|
||||||
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) list(c *gin.Context) {
|
func (a *ClientController) list(c *gin.Context) {
|
||||||
|
|
@ -220,27 +213,6 @@ type bulkDeleteRequest struct {
|
||||||
KeepTraffic bool `json:"keepTraffic"`
|
KeepTraffic bool `json:"keepTraffic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type bulkAssignGroupRequest struct {
|
|
||||||
Emails []string `json:"emails"`
|
|
||||||
Group string `json:"group"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ClientController) bulkAssignGroup(c *gin.Context) {
|
|
||||||
var req bulkAssignGroupRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
affected, err := a.clientService.AssignGroup(req.Emails, req.Group)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
notifyClientsChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
type bulkAttachRequest struct {
|
type bulkAttachRequest struct {
|
||||||
Emails []string `json:"emails"`
|
Emails []string `json:"emails"`
|
||||||
InboundIds []int `json:"inboundIds"`
|
InboundIds []int `json:"inboundIds"`
|
||||||
|
|
@ -471,25 +443,6 @@ func (a *ClientController) detach(c *gin.Context) {
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) listGroups(c *gin.Context) {
|
|
||||||
rows, err := a.clientService.ListGroups()
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, rows, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ClientController) groupEmails(c *gin.Context) {
|
|
||||||
name := c.Param("name")
|
|
||||||
emails, err := a.clientService.EmailsByGroup(name)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, emails, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
type bulkResetRequest struct {
|
type bulkResetRequest struct {
|
||||||
Emails []string `json:"emails"`
|
Emails []string `json:"emails"`
|
||||||
}
|
}
|
||||||
|
|
@ -509,62 +462,3 @@ func (a *ClientController) bulkResetTraffic(c *gin.Context) {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
type groupCreateBody struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ClientController) createGroup(c *gin.Context) {
|
|
||||||
var body groupCreateBody
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := a.clientService.CreateGroup(body.Name); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, gin.H{"name": body.Name}, nil)
|
|
||||||
notifyClientsChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
type groupRenameBody struct {
|
|
||||||
OldName string `json:"oldName"`
|
|
||||||
NewName string `json:"newName"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ClientController) renameGroup(c *gin.Context) {
|
|
||||||
var body groupRenameBody
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
|
||||||
notifyClientsChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
type groupDeleteBody struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ClientController) deleteGroup(c *gin.Context) {
|
|
||||||
var body groupDeleteBody
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
affected, err := a.clientService.DeleteGroup(body.Name)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
|
||||||
notifyClientsChanged()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
154
web/controller/group.go
Normal file
154
web/controller/group.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GroupController struct {
|
||||||
|
clientService service.ClientService
|
||||||
|
xrayService service.XrayService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGroupController(g *gin.RouterGroup) *GroupController {
|
||||||
|
a := &GroupController{}
|
||||||
|
a.initRouter(g)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g.GET("/groups", a.list)
|
||||||
|
g.GET("/groups/:name/emails", a.emails)
|
||||||
|
g.POST("/groups/create", a.create)
|
||||||
|
g.POST("/groups/rename", a.rename)
|
||||||
|
g.POST("/groups/delete", a.delete)
|
||||||
|
g.POST("/groups/bulkAdd", a.bulkAdd)
|
||||||
|
g.POST("/groups/bulkRemove", a.bulkRemove)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) list(c *gin.Context) {
|
||||||
|
rows, err := a.clientService.ListGroups()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, rows, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) emails(c *gin.Context) {
|
||||||
|
name := c.Param("name")
|
||||||
|
emails, err := a.clientService.EmailsByGroup(name)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, emails, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupCreateBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) create(c *gin.Context) {
|
||||||
|
var body groupCreateBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.clientService.CreateGroup(body.Name); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"name": body.Name}, nil)
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupRenameBody struct {
|
||||||
|
OldName string `json:"oldName"`
|
||||||
|
NewName string `json:"newName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) rename(c *gin.Context) {
|
||||||
|
var body groupRenameBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupDeleteBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) delete(c *gin.Context) {
|
||||||
|
var body groupDeleteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, err := a.clientService.DeleteGroup(body.Name)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
type bulkAddToGroupRequest struct {
|
||||||
|
Emails []string `json:"emails"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) bulkAdd(c *gin.Context) {
|
||||||
|
var req bulkAddToGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Group) == "" {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
type bulkRemoveFromGroupRequest struct {
|
||||||
|
Emails []string `json:"emails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *GroupController) bulkRemove(c *gin.Context) {
|
||||||
|
var req bulkRemoveFromGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, err := a.clientService.RemoveFromGroup(req.Emails)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
@ -1402,7 +1402,11 @@ func (s *ClientService) DeleteGroup(name string) (int, error) {
|
||||||
return s.replaceGroupValue(name, "")
|
return s.replaceGroupValue(name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClientService) AssignGroup(emails []string, group string) (int, error) {
|
func (s *ClientService) RemoveFromGroup(emails []string) (int, error) {
|
||||||
|
return s.AddToGroup(emails, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
|
||||||
group = strings.TrimSpace(group)
|
group = strings.TrimSpace(group)
|
||||||
if len(emails) == 0 {
|
if len(emails) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
|
|
@ -299,7 +301,7 @@
|
||||||
"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…",
|
"attachClients": "Attach Clients To…",
|
||||||
"assignClientsGroup": "Assign Clients To Group…",
|
"addClientsToGroup": "Add Clients To Group…",
|
||||||
"attachClientsTitle": "Attach clients from \"{remark}\"",
|
"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.",
|
"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",
|
"attachClientsTargets": "Target inbounds",
|
||||||
|
|
@ -536,12 +538,15 @@
|
||||||
"deleteSelected": "Delete ({count})",
|
"deleteSelected": "Delete ({count})",
|
||||||
"adjustSelected": "Adjust ({count})",
|
"adjustSelected": "Adjust ({count})",
|
||||||
"subLinksSelected": "Sub links ({count})",
|
"subLinksSelected": "Sub links ({count})",
|
||||||
"assignGroupSelected": "Group ({count})",
|
"addToGroupTitle": "Add {count} client(s) to a group",
|
||||||
"assignGroupTitle": "Assign group to {count} client(s)",
|
"addToGroupTooltip": "Pick an existing group or type a new name. Use the Ungroup action to remove clients from their current group.",
|
||||||
"assignGroupTooltip": "Pick an existing group or type a new name. Leave blank to clear the group on the selected clients.",
|
"addToGroupPlaceholder": "Group name",
|
||||||
"assignGroupPlaceholder": "Group name (leave blank to clear)",
|
"addToGroupSuccessToast": "Added {count} client(s) to {group}",
|
||||||
"assignGroupAssignedToast": "Assigned {count} client(s) to {group}",
|
"ungroupSuccessToast": "Cleared group from {count} client(s)",
|
||||||
"assignGroupClearedToast": "Cleared group from {count} client(s)",
|
"ungroup": "Ungroup",
|
||||||
|
"ungroupConfirmTitle": "Remove {count} client(s) from their group?",
|
||||||
|
"ungroupConfirmContent": "Clears the group label on each selected client. Clients themselves are kept (use Delete to remove them entirely).",
|
||||||
|
"addToGroup": "Add to group",
|
||||||
"attach": "Attach",
|
"attach": "Attach",
|
||||||
"adjust": "Adjust",
|
"adjust": "Adjust",
|
||||||
"subLinks": "Sub links",
|
"subLinks": "Sub links",
|
||||||
|
|
@ -629,7 +634,16 @@
|
||||||
"deleteClientsConfirmTitle": "Delete all clients in {name}?",
|
"deleteClientsConfirmTitle": "Delete all clients in {name}?",
|
||||||
"deleteClientsConfirmContent": "This permanently removes {count} client(s) along with their traffic records. The group label is cleared too. This cannot be undone.",
|
"deleteClientsConfirmContent": "This permanently removes {count} client(s) along with their traffic records. The group label is cleared too. This cannot be undone.",
|
||||||
"deleteClientsSuccess": "Deleted {count} client(s).",
|
"deleteClientsSuccess": "Deleted {count} client(s).",
|
||||||
"deleteClientsMixed": "{ok} deleted, {failed} skipped"
|
"deleteClientsMixed": "{ok} deleted, {failed} skipped",
|
||||||
|
"addToGroup": "Add clients…",
|
||||||
|
"addToGroupTitle": "Add clients to group \"{name}\"",
|
||||||
|
"addToGroupDesc": "Select clients to add to this group. They keep their existing inbound attachments; only the group label changes. Clients already in this group are not listed.",
|
||||||
|
"addToGroupEmpty": "No other clients available to add.",
|
||||||
|
"addToGroupResult": "Added {count} client(s) to {name}.",
|
||||||
|
"removeFromGroup": "Remove clients…",
|
||||||
|
"removeFromGroupTitle": "Remove clients from group \"{name}\"",
|
||||||
|
"removeFromGroupDesc": "Select members to remove from this group. Clients themselves are kept (use \"Delete clients in group\" to remove them entirely).",
|
||||||
|
"removeFromGroupResult": "Removed {count} client(s) from {name}."
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"title": "Nodes",
|
"title": "Nodes",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"logout": "خروج",
|
"logout": "خروج",
|
||||||
"create": "ایجاد",
|
"create": "ایجاد",
|
||||||
|
"add": "افزودن",
|
||||||
|
"remove": "حذف",
|
||||||
"update": "بهروزرسانی",
|
"update": "بهروزرسانی",
|
||||||
"copy": "کپی",
|
"copy": "کپی",
|
||||||
"copied": "کپی شد",
|
"copied": "کپی شد",
|
||||||
|
|
@ -294,7 +296,7 @@
|
||||||
"delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
|
"delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
|
||||||
"delAllClientsConfirmContent": "تمام کلاینتهای این اینباند به همراه رکوردهای ترافیکشان حذف میشوند. خود اینباند باقی میماند. این عمل غیرقابل بازگشت است.",
|
"delAllClientsConfirmContent": "تمام کلاینتهای این اینباند به همراه رکوردهای ترافیکشان حذف میشوند. خود اینباند باقی میماند. این عمل غیرقابل بازگشت است.",
|
||||||
"attachClients": "اتصال کلاینتها به…",
|
"attachClients": "اتصال کلاینتها به…",
|
||||||
"assignClientsGroup": "افزودن کلاینتها به گروه…",
|
"addClientsToGroup": "افزودن کلاینتها به گروه…",
|
||||||
"attachClientsTitle": "اتصال کلاینتهای «{remark}»",
|
"attachClientsTitle": "اتصال کلاینتهای «{remark}»",
|
||||||
"attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخابشده هم متصل میکند. روی این اینباند هم باقی میمانند.",
|
"attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخابشده هم متصل میکند. روی این اینباند هم باقی میمانند.",
|
||||||
"attachClientsTargets": "اینباندهای مقصد",
|
"attachClientsTargets": "اینباندهای مقصد",
|
||||||
|
|
@ -517,6 +519,10 @@
|
||||||
"adjust": "تنظیم",
|
"adjust": "تنظیم",
|
||||||
"subLinks": "لینکهای ساب",
|
"subLinks": "لینکهای ساب",
|
||||||
"selectedCount": "{count} انتخابشده",
|
"selectedCount": "{count} انتخابشده",
|
||||||
|
"ungroup": "حذف گروه",
|
||||||
|
"ungroupConfirmTitle": "{count} کلاینت از گروهشان حذف شود؟",
|
||||||
|
"ungroupConfirmContent": "برچسب گروه از هر کلاینت انتخابشده پاک میشود. خود کلاینتها حفظ میشوند (برای حذف کامل، از Delete استفاده کنید).",
|
||||||
|
"addToGroup": "افزودن به گروه",
|
||||||
"attachSelected": "اتصال ({count})",
|
"attachSelected": "اتصال ({count})",
|
||||||
"attachToInboundsTitle": "اتصال {count} کلاینت به اینباند(ها)",
|
"attachToInboundsTitle": "اتصال {count} کلاینت به اینباند(ها)",
|
||||||
"attachToInboundsDesc": "{count} کلاینت انتخابشده (با همان UUID/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل میشوند. روی اینباندهای فعلی هم باقی میمانند.",
|
"attachToInboundsDesc": "{count} کلاینت انتخابشده (با همان UUID/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل میشوند. روی اینباندهای فعلی هم باقی میمانند.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue