3x-ui/frontend/src/pages/clients/BulkAssignGroupModal.tsx

84 lines
2.3 KiB
TypeScript
Raw Normal View History

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.
2026-05-27 15:30:55 +00:00
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd';
interface BulkAssignGroupModalProps {
open: boolean;
count: number;
groups: string[];
onOpenChange: (open: boolean) => void;
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
}
export default function BulkAssignGroupModal({
open,
count,
groups,
onOpenChange,
onSubmit,
}: BulkAssignGroupModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) setValue('');
}, [open]);
async function submit() {
const next = value.trim();
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 }));
}
onOpenChange(false);
}
} finally {
setSubmitting(false);
}
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={t('pages.clients.assignGroupTitle', { count })}
okText={t('save')}
cancelText={t('cancel')}
confirmLoading={submitting}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item
label={t('pages.clients.group')}
tooltip={t('pages.clients.assignGroupTooltip')}
>
<AutoComplete
value={value}
placeholder={t('pages.clients.assignGroupPlaceholder')}
options={groups.map((g) => ({ value: g }))}
onChange={(v) => setValue(v ?? '')}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
}
allowClear
style={{ width: '100%' }}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
</>
);
}