mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-01 16:07:17 +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:
+10
-12
@@ -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) =>
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user