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:
MHSanaei
2026-05-28 12:59:20 +02:00
parent bf1b488a63
commit 530e338c66
20 changed files with 764 additions and 174 deletions
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd';
interface BulkAssignGroupModalProps {
interface BulkAddToGroupModalProps {
open: boolean;
count: number;
groups: string[];
@@ -10,13 +10,13 @@ interface BulkAssignGroupModalProps {
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
}
export default function BulkAssignGroupModal({
export default function BulkAddToGroupModal({
open,
count,
groups,
onOpenChange,
onSubmit,
}: BulkAssignGroupModalProps) {
}: BulkAddToGroupModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [value, setValue] = useState('');
@@ -28,16 +28,13 @@ export default function BulkAssignGroupModal({
async function submit() {
const next = value.trim();
if (!next) return;
setSubmitting(true);
try {
const result = await onSubmit(next);
if (result) {
const affected = result.affected ?? 0;
if (next === '') {
messageApi.success(t('pages.clients.assignGroupClearedToast', { count: affected }));
} else {
messageApi.success(t('pages.clients.assignGroupAssignedToast', { count: affected, group: next }));
}
messageApi.success(t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }));
onOpenChange(false);
}
} finally {
@@ -50,10 +47,11 @@ export default function BulkAssignGroupModal({
{messageContextHolder}
<Modal
open={open}
title={t('pages.clients.assignGroupTitle', { count })}
okText={t('save')}
title={t('pages.clients.addToGroupTitle', { count })}
okText={t('add')}
cancelText={t('cancel')}
confirmLoading={submitting}
okButtonProps={{ disabled: !value.trim() }}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
@@ -61,11 +59,11 @@ export default function BulkAssignGroupModal({
<Form layout="vertical">
<Form.Item
label={t('pages.clients.group')}
tooltip={t('pages.clients.assignGroupTooltip')}
tooltip={t('pages.clients.addToGroupTooltip')}
>
<AutoComplete
value={value}
placeholder={t('pages.clients.assignGroupPlaceholder')}
placeholder={t('pages.clients.addToGroupPlaceholder')}
options={groups.map((g) => ({ value: g }))}
onChange={(v) => setValue(v ?? '')}
filterOption={(input, option) =>
+71 -11
View File
@@ -62,7 +62,7 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer'));
const SubLinksModal = lazy(() => import('./SubLinksModal'));
const BulkAssignGroupModal = lazy(() => import('./BulkAssignGroupModal'));
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
import { emptyFilters, activeFilterCount } from './filters';
@@ -71,6 +71,45 @@ import './ClientsPage.css';
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';
interface PersistedFilterState {
@@ -152,7 +191,7 @@ export default function ClientsPage() {
setQuery,
inbounds, onlines, loading, fetched, subSettings,
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,
applyTrafficEvent, applyClientStatsEvent,
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() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
@@ -586,6 +645,7 @@ export default function ClientsPage() {
title: t('pages.clients.group'),
key: 'group',
width: 130,
hidden: allGroups.length === 0,
render: (_v, record) => {
if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>;
const isActive = filters.groups.includes(record.group);
@@ -670,7 +730,7 @@ export default function ClientsPage() {
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters]);
], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups]);
const tablePagination = {
current: currentPage,
@@ -803,6 +863,12 @@ export default function ClientsPage() {
<Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
{!isMobile && t('pages.clients.detach')}
</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
@@ -817,12 +883,6 @@ export default function ClientsPage() {
label: t('pages.clients.adjust'),
onClick: () => setBulkAdjustOpen(true),
},
{
key: 'group',
icon: <TagsOutlined />,
label: t('pages.clients.group'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'subLinks',
icon: <LinkOutlined />,
@@ -1181,13 +1241,13 @@ export default function ClientsPage() {
/>
</LazyMount>
<LazyMount when={bulkGroupOpen}>
<BulkAssignGroupModal
<BulkAddToGroupModal
open={bulkGroupOpen}
count={selectedRowKeys.length}
groups={allGroups}
onOpenChange={setBulkGroupOpen}
onSubmit={async (group) => {
const msg = await bulkAssignGroup([...selectedRowKeys], group);
const msg = await bulkAddToGroup([...selectedRowKeys], group);
if (msg?.success) {
setSelectedRowKeys([]);
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };