mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
Persistent client groups
- New ClientGroup model + client_groups table that holds empty
(placeholder) groups so a user can define a label before any client
references it. ListGroups merges these with the distinct group_name
values already stored on clients and reports {name, clientCount}.
- ClientRecord gains group_name column; the model.Client wire shape
gains a matching `group` JSON field that survives the
inbound.settings → SyncInbound round-trip.
- Rename/Delete on a group mutates client_groups (rename row / delete
row) AND propagates to all matching clients in ClientRecord and in
every owning inbound's settings JSON, all in one transaction.
Bulk operations
- AssignGroup(emails, group) updates clients.group_name + patches each
affected inbound's settings JSON in one read-modify-write per inbound.
Empty group clears the label. Auto-creates the client_groups row when
the user assigns to a brand-new name.
- BulkResetTraffic(emails) loops the existing single-reset path so the
caller can zero traffic across a whole selection or a whole group.
- EmailsByGroup(name) returns just the email list (used by the groups
page to fan a single bulk action over every member).
Endpoints (all under /panel/api/clients)
- GET /groups — summaries with counts
- GET /groups/:name/emails — emails in a group
- POST /groups/create — empty placeholder group
- POST /groups/rename — rename (table + clients + JSON)
- POST /groups/delete — drop label everywhere (clients survive)
- POST /bulkAssignGroup — assign N selected clients
- POST /bulkResetTraffic — reset traffic on a list
Clients page UX
- New Group column (Actions → Client → Group → Inbounds → …) with a
click-to-filter chip.
- FilterDrawer gains a multi-select Group filter whose options come
from the new ClientPageResponse.groups field (sourced from ListGroups
so empty/placeholder groups are pickable too).
- Single-client and bulk-add forms gain a Group AutoComplete pre-loaded
with all known group names.
- New toolbar buttons when selection > 0: "Group ({n})" opens
BulkAssignGroupModal, "Sub links ({n})" opens SubLinksModal.
Sub-links export modal (new SubLinksModal.tsx)
- Table of selected clients with their subscription URL (and JSON URL
when subJsonEnable is on), per-row copy, Copy all, and Download as
sub-links-<timestamp>.txt. Warns when subscription is disabled or
none of the selected clients have a subId.
Dedicated Groups page (new pages/groups/GroupsPage.tsx)
- /groups route + sidebar entry (TagsOutlined icon) + page title key.
- Card-based layout matching Clients/Inbounds/Nodes — summary card with
Total/Grouped/Empty stats, main card with Add Group button + table.
- Per-row More dropdown (icon-first column on the left): Sub links,
Adjust (days+traffic), Reset traffic, Rename, Delete clients in
group, Delete group (keep clients). Empty groups disable the
client-targeted actions.
- Reuses SubLinksModal and ClientBulkAdjustModal — emails for the
group are fetched on demand from GET /groups/:name/emails.
Other polish
- /groups + groups-page selectors added to page-shell.css and
page-cards.css so the new page inherits the same background, padding,
card borders, hover shadow, and summary-card padding.
- .card-toolbar gains a small vertical padding so the larger toolbar
buttons (now default size, matching Inbounds) don't crowd the top of
the card-head on Clients and Groups pages.
44 lines
1.7 KiB
TypeScript
44 lines
1.7 KiB
TypeScript
import { lazy, Suspense } from 'react';
|
|
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
|
|
|
|
import PanelLayout from '@/layouts/PanelLayout';
|
|
|
|
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
|
|
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
|
|
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
|
|
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
|
|
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
|
|
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
|
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
|
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
|
|
|
function withSuspense(node: React.ReactNode) {
|
|
return <Suspense fallback={null}>{node}</Suspense>;
|
|
}
|
|
|
|
const routes: RouteObject[] = [
|
|
{
|
|
path: '/',
|
|
element: <PanelLayout />,
|
|
children: [
|
|
{ index: true, element: withSuspense(<IndexPage />) },
|
|
{ path: 'inbounds', element: withSuspense(<InboundsPage />) },
|
|
{ path: 'clients', element: withSuspense(<ClientsPage />) },
|
|
{ path: 'groups', element: withSuspense(<GroupsPage />) },
|
|
{ path: 'nodes', element: withSuspense(<NodesPage />) },
|
|
{ path: 'settings', element: withSuspense(<SettingsPage />) },
|
|
{ path: 'xray', element: withSuspense(<XrayPage />) },
|
|
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
|
],
|
|
},
|
|
];
|
|
|
|
function computeBasename() {
|
|
const raw = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '/';
|
|
const trimmed = raw.replace(/\/+$/, '');
|
|
return `${trimmed}/panel`;
|
|
}
|
|
|
|
export const router = createBrowserRouter(routes, {
|
|
basename: computeBasename(),
|
|
});
|