| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528 |
- 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<GroupSummary[]> {
- 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<string[]> {
- const msg = await HttpUtil.get<string[]>(
- `/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<GroupSummary | null>(null);
- const [renameValue, setRenameValue] = useState('');
- const [subLinksOpen, setSubLinksOpen] = useState(false);
- const [adjustOpen, setAdjustOpen] = useState(false);
- const [groupEmails, setGroupEmails] = useState<string[]>([]);
- const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(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: <LinkOutlined />,
- label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
- disabled: !row.clientCount,
- onClick: () => openSubLinksFor(row),
- },
- {
- key: 'adjust',
- icon: <ClockCircleOutlined />,
- label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
- disabled: !row.clientCount,
- onClick: () => openAdjustFor(row),
- },
- {
- key: 'reset',
- icon: <RetweetOutlined />,
- label: t('pages.groups.resetTraffic'),
- disabled: !row.clientCount,
- onClick: () => onResetTraffic(row),
- },
- { type: 'divider' },
- {
- key: 'rename',
- icon: <EditOutlined />,
- label: t('pages.groups.rename'),
- onClick: () => openRename(row),
- },
- {
- key: 'deleteClients',
- icon: <DeleteOutlined />,
- label: t('pages.groups.deleteClients'),
- danger: true,
- disabled: !row.clientCount,
- onClick: () => onDeleteClients(row),
- },
- {
- key: 'delete',
- icon: <DeleteOutlined />,
- label: t('pages.groups.deleteGroupOnly'),
- danger: true,
- onClick: () => onDelete(row),
- },
- ];
- }
- const columns: TableColumnsType<GroupSummary> = [
- {
- title: t('pages.clients.actions'),
- key: 'actions',
- width: 90,
- render: (_v, row) => (
- <Space size={4}>
- <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
- <Button size="small" type="text" icon={<MoreOutlined />} />
- </Dropdown>
- <Tooltip title={t('pages.groups.rename')}>
- <Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} />
- </Tooltip>
- </Space>
- ),
- },
- {
- title: t('pages.groups.name'),
- dataIndex: 'name',
- key: 'name',
- render: (name: string) => <Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>{name}</Tag>,
- },
- {
- title: t('pages.groups.clientCount'),
- dataIndex: 'clientCount',
- key: 'clientCount',
- width: 180,
- render: (count: number) => <span>{count || 0}</span>,
- },
- ];
- const pageClass = useMemo(() => {
- const classes = ['groups-page'];
- if (isDark) classes.push('is-dark');
- if (isUltra) classes.push('is-ultra');
- return classes.join(' ');
- }, [isDark, isUltra]);
- return (
- <ConfigProvider theme={antdThemeConfig}>
- {messageContextHolder}
- {modalContextHolder}
- <Layout className={pageClass}>
- <AppSidebar />
- <Layout className="content-shell">
- <Layout.Content id="content-layout" className="content-area">
- <Spin spinning={!fetched} delay={200} description="Loading…" size="large">
- {!fetched ? (
- <div className="loading-spacer" />
- ) : (
- <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
- <Col span={24}>
- <Card size="small" hoverable className="summary-card">
- <Row gutter={[16, isMobile ? 16 : 12]}>
- <Col xs={12} sm={8} md={6}>
- <Statistic
- title={t('pages.groups.totalGroups')}
- value={String(totalGroups)}
- prefix={<TagsOutlined />}
- />
- </Col>
- <Col xs={12} sm={8} md={6}>
- <Statistic
- title={t('pages.groups.totalGroupedClients')}
- value={String(totalClients)}
- prefix={<TeamOutlined />}
- />
- </Col>
- <Col xs={12} sm={8} md={6}>
- <Statistic
- title={t('pages.groups.emptyGroups')}
- value={String(emptyGroups)}
- />
- </Col>
- </Row>
- </Card>
- </Col>
- <Col span={24}>
- <Card
- size="small"
- hoverable
- title={
- <div className="card-toolbar">
- <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
- {!isMobile && t('pages.groups.addGroup')}
- </Button>
- </div>
- }
- >
- <Table<GroupSummary>
- dataSource={groups}
- columns={columns}
- rowKey="name"
- size="small"
- pagination={false}
- loading={loading}
- locale={{
- emptyText: (
- <div className="card-empty">
- <TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
- <div>{t('noData')}</div>
- </div>
- ),
- }}
- />
- </Card>
- </Col>
- </Row>
- )}
- </Spin>
- </Layout.Content>
- </Layout>
- <Modal
- open={createOpen}
- title={t('pages.groups.addGroup')}
- okText={t('create')}
- cancelText={t('cancel')}
- confirmLoading={createMut.isPending}
- onCancel={() => setCreateOpen(false)}
- onOk={confirmCreate}
- destroyOnHidden
- >
- <Form layout="vertical">
- <Form.Item label={t('pages.groups.name')}>
- <Input
- value={createName}
- onChange={(e) => setCreateName(e.target.value)}
- onPressEnter={confirmCreate}
- placeholder={t('pages.clients.groupPlaceholder')}
- autoFocus
- />
- </Form.Item>
- </Form>
- </Modal>
- <Modal
- open={renameOpen}
- title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
- okText={t('save')}
- cancelText={t('cancel')}
- confirmLoading={renameMut.isPending}
- onCancel={() => setRenameOpen(false)}
- onOk={confirmRename}
- destroyOnHidden
- >
- <Form layout="vertical">
- <Form.Item label={t('pages.groups.name')}>
- <Input
- value={renameValue}
- onChange={(e) => setRenameValue(e.target.value)}
- onPressEnter={confirmRename}
- placeholder={t('pages.clients.groupPlaceholder')}
- autoFocus
- />
- </Form.Item>
- </Form>
- </Modal>
- <LazyMount when={subLinksOpen}>
- <SubLinksModal
- open={subLinksOpen}
- emails={groupEmails}
- clients={clients}
- subSettings={subSettings}
- onOpenChange={setSubLinksOpen}
- />
- </LazyMount>
- <LazyMount when={adjustOpen}>
- <ClientBulkAdjustModal
- open={adjustOpen}
- count={groupEmails.length}
- onOpenChange={setAdjustOpen}
- onSubmit={async (addDays, addBytes) => {
- 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;
- }}
- />
- </LazyMount>
- </Layout>
- </ConfigProvider>
- );
- }
|