import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Card, Col, ConfigProvider, Dropdown, Form, Input, Layout, Modal, Row, Space, Spin, Statistic, Table, Tag, Tooltip, message, } from 'antd'; import type { MenuProps, TableColumnsType } from 'antd'; import { ClockCircleOutlined, DeleteOutlined, EditOutlined, LinkOutlined, MoreOutlined, PlusOutlined, RetweetOutlined, TagsOutlined, TeamOutlined, } from '@ant-design/icons'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useClients } from '@/hooks/useClients'; import { HttpUtil } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; import AppSidebar from '@/components/AppSidebar'; import LazyMount from '@/components/LazyMount'; import { keys } from '@/api/queryKeys'; import { GroupSummaryListSchema, type GroupSummary } from '@/schemas/client'; import { parseMsg } from '@/utils/zodValidate'; const SubLinksModal = lazy(() => import('../clients/SubLinksModal')); const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal')); const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; async function fetchGroups(): Promise { const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups'); const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups'); return validated.obj ?? []; } async function fetchEmailsForGroup(name: string): Promise { const msg = await HttpUtil.get( `/panel/api/clients/groups/${encodeURIComponent(name)}/emails`, undefined, { silent: true }, ); if (!msg?.success || !Array.isArray(msg.obj)) return []; return msg.obj; } export default function GroupsPage() { usePageTitle(); const { t } = useTranslation(); const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); const queryClient = useQueryClient(); const { clients, subSettings, bulkAdjust, bulkDelete } = useClients(); const groupsQuery = useQuery({ queryKey: keys.clients.groups(), queryFn: fetchGroups, }); const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); const loading = groupsQuery.isFetching; const fetched = groupsQuery.data !== undefined; const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: keys.clients.root() }); }, [queryClient]); const createMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const renameMut = useMutation({ mutationFn: (body: { oldName: string; newName: string }) => HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const deleteMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const bulkResetMut = useMutation({ mutationFn: (body: { emails: string[] }) => HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(''); const [renameOpen, setRenameOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [renameValue, setRenameValue] = useState(''); const [subLinksOpen, setSubLinksOpen] = useState(false); const [adjustOpen, setAdjustOpen] = useState(false); const [groupEmails, setGroupEmails] = useState([]); const [groupForAction, setGroupForAction] = useState(null); const totalGroups = groups.length; const totalClients = useMemo( () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0), [groups], ); const emptyGroups = useMemo( () => groups.filter((g) => (g.clientCount || 0) === 0).length, [groups], ); function openCreate() { setCreateName(''); setCreateOpen(true); } async function confirmCreate() { const name = createName.trim(); if (!name) return; if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) { messageApi.error(t('pages.groups.renameCollision', { name })); return; } const msg = await createMut.mutateAsync({ name }); if (msg?.success) { messageApi.success(t('pages.groups.createSuccess', { name })); setCreateOpen(false); } } function openRename(g: GroupSummary) { setRenameTarget(g); setRenameValue(g.name); setRenameOpen(true); } async function confirmRename() { if (!renameTarget) return; const next = renameValue.trim(); if (!next || next === renameTarget.name) { setRenameOpen(false); return; } if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) { messageApi.error(t('pages.groups.renameCollision', { name: next })); return; } const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next }); if (msg?.success) { const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0; messageApi.success(t('pages.groups.renameSuccess', { count: affected })); setRenameOpen(false); } } function onDelete(g: GroupSummary) { modal.confirm({ title: t('pages.groups.deleteConfirmTitle', { name: g.name }), content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }), okText: t('delete'), okType: 'danger', cancelText: t('cancel'), onOk: async () => { const msg = await deleteMut.mutateAsync({ name: g.name }); if (msg?.success) { const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0; messageApi.success(t('pages.groups.deleteSuccess', { count: affected })); } }, }); } async function openSubLinksFor(g: GroupSummary) { if (!g.clientCount) { messageApi.info(t('pages.groups.emptyForAction')); return; } const emails = await fetchEmailsForGroup(g.name); if (emails.length === 0) { messageApi.info(t('pages.groups.emptyForAction')); return; } setGroupForAction(g); setGroupEmails(emails); setSubLinksOpen(true); } async function openAdjustFor(g: GroupSummary) { if (!g.clientCount) { messageApi.info(t('pages.groups.emptyForAction')); return; } const emails = await fetchEmailsForGroup(g.name); if (emails.length === 0) { messageApi.info(t('pages.groups.emptyForAction')); return; } setGroupForAction(g); setGroupEmails(emails); setAdjustOpen(true); } function onDeleteClients(g: GroupSummary) { if (!g.clientCount) { messageApi.info(t('pages.groups.emptyForAction')); return; } modal.confirm({ title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }), content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }), okText: t('delete'), okType: 'danger', cancelText: t('cancel'), onOk: async () => { const emails = await fetchEmailsForGroup(g.name); if (emails.length === 0) return; const msg = await bulkDelete(emails); if (msg?.success) { const ok = msg.obj?.deleted ?? 0; const skipped = msg.obj?.skipped ?? []; const failed = skipped.length; if (failed === 0) { messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok })); } else { const firstError = skipped[0]?.reason ?? msg?.msg ?? ''; messageApi.warning(firstError ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}` : t('pages.groups.deleteClientsMixed', { ok, failed })); } } }, }); } function onResetTraffic(g: GroupSummary) { if (!g.clientCount) { messageApi.info(t('pages.groups.emptyForAction')); return; } modal.confirm({ title: t('pages.groups.resetConfirmTitle', { name: g.name }), content: t('pages.groups.resetConfirmContent', { count: g.clientCount }), okText: t('reset'), okType: 'danger', cancelText: t('cancel'), onOk: async () => { const emails = await fetchEmailsForGroup(g.name); if (emails.length === 0) return; const msg = await bulkResetMut.mutateAsync({ emails }); if (msg?.success) { const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length; messageApi.success(t('pages.groups.resetSuccess', { count: affected })); } }, }); } function rowActions(row: GroupSummary): MenuProps['items'] { return [ { key: 'subLinks', icon: , label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }), disabled: !row.clientCount, onClick: () => openSubLinksFor(row), }, { key: 'adjust', icon: , label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }), disabled: !row.clientCount, onClick: () => openAdjustFor(row), }, { key: 'reset', icon: , label: t('pages.groups.resetTraffic'), disabled: !row.clientCount, onClick: () => onResetTraffic(row), }, { type: 'divider' }, { key: 'rename', icon: , label: t('pages.groups.rename'), onClick: () => openRename(row), }, { key: 'deleteClients', icon: , label: t('pages.groups.deleteClients'), danger: true, disabled: !row.clientCount, onClick: () => onDeleteClients(row), }, { key: 'delete', icon: , label: t('pages.groups.deleteGroupOnly'), danger: true, onClick: () => onDelete(row), }, ]; } const columns: TableColumnsType = [ { title: t('pages.clients.actions'), key: 'actions', width: 90, render: (_v, row) => ( } > dataSource={groups} columns={columns} rowKey="name" size="small" pagination={false} loading={loading} locale={{ emptyText: (
{t('noData')}
), }} /> )} setCreateOpen(false)} onOk={confirmCreate} destroyOnHidden >
setCreateName(e.target.value)} onPressEnter={confirmCreate} placeholder={t('pages.clients.groupPlaceholder')} autoFocus />
setRenameOpen(false)} onOk={confirmRename} destroyOnHidden >
setRenameValue(e.target.value)} onPressEnter={confirmRename} placeholder={t('pages.clients.groupPlaceholder')} autoFocus />
{ const msg = await bulkAdjust(groupEmails, addDays, addBytes); if (msg?.success) { const obj = msg.obj ?? { adjusted: 0 }; messageApi.success( t('pages.groups.adjustSuccess', { count: obj.adjusted ?? 0, name: groupForAction?.name ?? '', }), ); return obj; } return null; }} /> ); }