feat(clients,groups): client groups + sub-links export + dedicated groups page

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.
This commit is contained in:
MHSanaei
2026-05-27 17:30:55 +02:00
parent 7680e27d1d
commit 93eda06878
25 changed files with 2052 additions and 42 deletions
+41 -18
View File
@@ -18,7 +18,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px;
padding: 14px 16px 14px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
}
@@ -32,29 +32,12 @@
.brand-block {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 0;
line-height: 1.1;
}
.brand-text {
display: block;
}
.brand-version {
display: block;
width: 100%;
text-align: center;
font-size: 10px;
font-weight: 500;
letter-spacing: 0;
opacity: 0.6;
margin-top: 2px;
}
.sider-brand-collapsed .brand-block {
align-items: center;
flex: 0 0 auto;
}
@@ -206,6 +189,46 @@
border-top: 1px solid var(--ant-color-border-secondary);
}
.sider-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
}
.sider-version {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
padding: 8px 16px;
color: var(--ant-color-text-secondary);
font-size: 13px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
text-decoration: none;
transition: color 0.2s;
}
.sider-version .anticon {
font-size: 16px;
}
.sider-version:hover,
.sider-version:focus-visible {
color: var(--ant-color-primary);
outline: none;
}
.sider-version.is-collapsed {
justify-content: center;
padding: 8px 0;
}
.drawer-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
+30 -5
View File
@@ -9,6 +9,7 @@ import {
ClusterOutlined,
CloseOutlined,
DashboardOutlined,
GithubOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
@@ -17,6 +18,7 @@ import {
MoonOutlined,
SettingOutlined,
SunOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
@@ -27,14 +29,16 @@ import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'inbound' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
inbound: ImportOutlined,
team: TeamOutlined,
groups: TagsOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
@@ -65,6 +69,24 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
);
}
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = `v${version}`;
return (
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
aria-label={`GitHub ${label}`}
title={label}
>
<GithubOutlined />
{!collapsed && <span className="sider-version-text">{label}</span>}
</a>
);
}
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
id: string;
isDark: boolean;
@@ -103,6 +125,7 @@ export default function AppSidebar() {
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
@@ -171,9 +194,6 @@ export default function AppSidebar() {
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
{!collapsed && panelVersion && (
<span className="brand-version">v{panelVersion}</span>
)}
</div>
{!collapsed && (
<div className="brand-actions">
@@ -204,6 +224,9 @@ export default function AppSidebar() {
items={toMenuItems(utilItems)}
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} />
</div>
</Layout.Sider>
<Drawer
@@ -222,7 +245,6 @@ export default function AppSidebar() {
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">3X-UI</span>
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
</div>
<div className="drawer-header-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -259,6 +281,9 @@ export default function AppSidebar() {
items={toMenuItems(utilItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
/>
<div className="drawer-footer">
<VersionBadge version={panelVersion} />
</div>
</Drawer>
{!drawerOpen && (