GroupsPage.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Card,
  6. Col,
  7. ConfigProvider,
  8. Dropdown,
  9. Form,
  10. Input,
  11. Layout,
  12. Modal,
  13. Row,
  14. Space,
  15. Spin,
  16. Statistic,
  17. Table,
  18. Tag,
  19. Tooltip,
  20. message,
  21. } from 'antd';
  22. import type { MenuProps, TableColumnsType } from 'antd';
  23. import {
  24. ClockCircleOutlined,
  25. DeleteOutlined,
  26. EditOutlined,
  27. LinkOutlined,
  28. MoreOutlined,
  29. PlusOutlined,
  30. RetweetOutlined,
  31. TagsOutlined,
  32. TeamOutlined,
  33. UsergroupAddOutlined,
  34. UsergroupDeleteOutlined,
  35. } from '@ant-design/icons';
  36. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  37. import { useTheme } from '@/hooks/useTheme';
  38. import { useMediaQuery } from '@/hooks/useMediaQuery';
  39. import { usePageTitle } from '@/hooks/usePageTitle';
  40. import { useClients } from '@/hooks/useClients';
  41. import { HttpUtil } from '@/utils';
  42. import { setMessageInstance } from '@/utils/messageBus';
  43. import AppSidebar from '@/components/AppSidebar';
  44. import LazyMount from '@/components/LazyMount';
  45. import { keys } from '@/api/queryKeys';
  46. import { GroupSummaryListSchema, type GroupSummary } from '@/schemas/client';
  47. import { parseMsg } from '@/utils/zodValidate';
  48. const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
  49. const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
  50. const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
  51. const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
  52. const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
  53. async function fetchGroups(): Promise<GroupSummary[]> {
  54. const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
  55. if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
  56. const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
  57. return validated.obj ?? [];
  58. }
  59. async function fetchEmailsForGroup(name: string): Promise<string[]> {
  60. const msg = await HttpUtil.get<string[]>(
  61. `/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
  62. undefined,
  63. { silent: true },
  64. );
  65. if (!msg?.success || !Array.isArray(msg.obj)) return [];
  66. return msg.obj;
  67. }
  68. export default function GroupsPage() {
  69. usePageTitle();
  70. const { t } = useTranslation();
  71. const { isDark, isUltra, antdThemeConfig } = useTheme();
  72. const { isMobile } = useMediaQuery();
  73. const [modal, modalContextHolder] = Modal.useModal();
  74. const [messageApi, messageContextHolder] = message.useMessage();
  75. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  76. const queryClient = useQueryClient();
  77. const { clients, subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
  78. const groupsQuery = useQuery({
  79. queryKey: keys.clients.groups(),
  80. queryFn: fetchGroups,
  81. });
  82. const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
  83. const loading = groupsQuery.isFetching;
  84. const fetched = groupsQuery.data !== undefined;
  85. const invalidate = useCallback(() => {
  86. queryClient.invalidateQueries({ queryKey: keys.clients.root() });
  87. }, [queryClient]);
  88. const createMut = useMutation({
  89. mutationFn: (body: { name: string }) =>
  90. HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
  91. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  92. });
  93. const renameMut = useMutation({
  94. mutationFn: (body: { oldName: string; newName: string }) =>
  95. HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
  96. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  97. });
  98. const deleteMut = useMutation({
  99. mutationFn: (body: { name: string }) =>
  100. HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
  101. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  102. });
  103. const bulkResetMut = useMutation({
  104. mutationFn: (body: { emails: string[] }) =>
  105. HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS),
  106. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  107. });
  108. const [createOpen, setCreateOpen] = useState(false);
  109. const [createName, setCreateName] = useState('');
  110. const [renameOpen, setRenameOpen] = useState(false);
  111. const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
  112. const [renameValue, setRenameValue] = useState('');
  113. const [subLinksOpen, setSubLinksOpen] = useState(false);
  114. const [adjustOpen, setAdjustOpen] = useState(false);
  115. const [addClientsOpen, setAddClientsOpen] = useState(false);
  116. const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
  117. const [groupEmails, setGroupEmails] = useState<string[]>([]);
  118. const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
  119. const totalGroups = groups.length;
  120. const totalClients = useMemo(
  121. () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
  122. [groups],
  123. );
  124. const emptyGroups = useMemo(
  125. () => groups.filter((g) => (g.clientCount || 0) === 0).length,
  126. [groups],
  127. );
  128. function openCreate() {
  129. setCreateName('');
  130. setCreateOpen(true);
  131. }
  132. async function confirmCreate() {
  133. const name = createName.trim();
  134. if (!name) return;
  135. if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
  136. messageApi.error(t('pages.groups.renameCollision', { name }));
  137. return;
  138. }
  139. const msg = await createMut.mutateAsync({ name });
  140. if (msg?.success) {
  141. messageApi.success(t('pages.groups.createSuccess', { name }));
  142. setCreateOpen(false);
  143. }
  144. }
  145. function openRename(g: GroupSummary) {
  146. setRenameTarget(g);
  147. setRenameValue(g.name);
  148. setRenameOpen(true);
  149. }
  150. async function confirmRename() {
  151. if (!renameTarget) return;
  152. const next = renameValue.trim();
  153. if (!next || next === renameTarget.name) {
  154. setRenameOpen(false);
  155. return;
  156. }
  157. if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) {
  158. messageApi.error(t('pages.groups.renameCollision', { name: next }));
  159. return;
  160. }
  161. const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
  162. if (msg?.success) {
  163. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
  164. messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
  165. setRenameOpen(false);
  166. }
  167. }
  168. function onDelete(g: GroupSummary) {
  169. modal.confirm({
  170. title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
  171. content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
  172. okText: t('delete'),
  173. okType: 'danger',
  174. cancelText: t('cancel'),
  175. onOk: async () => {
  176. const msg = await deleteMut.mutateAsync({ name: g.name });
  177. if (msg?.success) {
  178. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
  179. messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
  180. }
  181. },
  182. });
  183. }
  184. async function openSubLinksFor(g: GroupSummary) {
  185. if (!g.clientCount) {
  186. messageApi.info(t('pages.groups.emptyForAction'));
  187. return;
  188. }
  189. const emails = await fetchEmailsForGroup(g.name);
  190. if (emails.length === 0) {
  191. messageApi.info(t('pages.groups.emptyForAction'));
  192. return;
  193. }
  194. setGroupForAction(g);
  195. setGroupEmails(emails);
  196. setSubLinksOpen(true);
  197. }
  198. async function openAdjustFor(g: GroupSummary) {
  199. if (!g.clientCount) {
  200. messageApi.info(t('pages.groups.emptyForAction'));
  201. return;
  202. }
  203. const emails = await fetchEmailsForGroup(g.name);
  204. if (emails.length === 0) {
  205. messageApi.info(t('pages.groups.emptyForAction'));
  206. return;
  207. }
  208. setGroupForAction(g);
  209. setGroupEmails(emails);
  210. setAdjustOpen(true);
  211. }
  212. function openAddClientsFor(g: GroupSummary) {
  213. setGroupForAction(g);
  214. setAddClientsOpen(true);
  215. }
  216. function openRemoveClientsFor(g: GroupSummary) {
  217. if (!g.clientCount) {
  218. messageApi.info(t('pages.groups.emptyForAction'));
  219. return;
  220. }
  221. setGroupForAction(g);
  222. setRemoveClientsOpen(true);
  223. }
  224. function onDeleteClients(g: GroupSummary) {
  225. if (!g.clientCount) {
  226. messageApi.info(t('pages.groups.emptyForAction'));
  227. return;
  228. }
  229. modal.confirm({
  230. title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
  231. content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
  232. okText: t('delete'),
  233. okType: 'danger',
  234. cancelText: t('cancel'),
  235. onOk: async () => {
  236. const emails = await fetchEmailsForGroup(g.name);
  237. if (emails.length === 0) return;
  238. const msg = await bulkDelete(emails);
  239. if (msg?.success) {
  240. const ok = msg.obj?.deleted ?? 0;
  241. const skipped = msg.obj?.skipped ?? [];
  242. const failed = skipped.length;
  243. if (failed === 0) {
  244. messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
  245. } else {
  246. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  247. messageApi.warning(firstError
  248. ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
  249. : t('pages.groups.deleteClientsMixed', { ok, failed }));
  250. }
  251. }
  252. },
  253. });
  254. }
  255. function onResetTraffic(g: GroupSummary) {
  256. if (!g.clientCount) {
  257. messageApi.info(t('pages.groups.emptyForAction'));
  258. return;
  259. }
  260. modal.confirm({
  261. title: t('pages.groups.resetConfirmTitle', { name: g.name }),
  262. content: t('pages.groups.resetConfirmContent', { count: g.clientCount }),
  263. okText: t('reset'),
  264. okType: 'danger',
  265. cancelText: t('cancel'),
  266. onOk: async () => {
  267. const emails = await fetchEmailsForGroup(g.name);
  268. if (emails.length === 0) return;
  269. const msg = await bulkResetMut.mutateAsync({ emails });
  270. if (msg?.success) {
  271. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
  272. messageApi.success(t('pages.groups.resetSuccess', { count: affected }));
  273. }
  274. },
  275. });
  276. }
  277. function rowActions(row: GroupSummary): MenuProps['items'] {
  278. return [
  279. {
  280. key: 'subLinks',
  281. icon: <LinkOutlined />,
  282. label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
  283. disabled: !row.clientCount,
  284. onClick: () => openSubLinksFor(row),
  285. },
  286. {
  287. key: 'adjust',
  288. icon: <ClockCircleOutlined />,
  289. label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
  290. disabled: !row.clientCount,
  291. onClick: () => openAdjustFor(row),
  292. },
  293. {
  294. key: 'reset',
  295. icon: <RetweetOutlined />,
  296. label: t('pages.groups.resetTraffic'),
  297. disabled: !row.clientCount,
  298. onClick: () => onResetTraffic(row),
  299. },
  300. {
  301. key: 'addClients',
  302. icon: <UsergroupAddOutlined />,
  303. label: t('pages.groups.addToGroup'),
  304. onClick: () => openAddClientsFor(row),
  305. },
  306. {
  307. key: 'rename',
  308. icon: <EditOutlined />,
  309. label: t('pages.groups.rename'),
  310. onClick: () => openRename(row),
  311. },
  312. { type: 'divider' },
  313. {
  314. key: 'removeClients',
  315. icon: <UsergroupDeleteOutlined />,
  316. label: t('pages.groups.removeFromGroup'),
  317. danger: true,
  318. disabled: !row.clientCount,
  319. onClick: () => openRemoveClientsFor(row),
  320. },
  321. {
  322. key: 'deleteClients',
  323. icon: <DeleteOutlined />,
  324. label: t('pages.groups.deleteClients'),
  325. danger: true,
  326. disabled: !row.clientCount,
  327. onClick: () => onDeleteClients(row),
  328. },
  329. {
  330. key: 'delete',
  331. icon: <DeleteOutlined />,
  332. label: t('pages.groups.deleteGroupOnly'),
  333. danger: true,
  334. onClick: () => onDelete(row),
  335. },
  336. ];
  337. }
  338. const columns: TableColumnsType<GroupSummary> = [
  339. {
  340. title: t('pages.clients.actions'),
  341. key: 'actions',
  342. width: 90,
  343. render: (_v, row) => (
  344. <Space size={4}>
  345. <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
  346. <Button size="small" type="text" icon={<MoreOutlined />} />
  347. </Dropdown>
  348. <Tooltip title={t('pages.groups.rename')}>
  349. <Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} />
  350. </Tooltip>
  351. </Space>
  352. ),
  353. },
  354. {
  355. title: t('pages.groups.name'),
  356. dataIndex: 'name',
  357. key: 'name',
  358. render: (name: string) => <Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>{name}</Tag>,
  359. },
  360. {
  361. title: t('pages.groups.clientCount'),
  362. dataIndex: 'clientCount',
  363. key: 'clientCount',
  364. width: 180,
  365. render: (count: number) => <span>{count || 0}</span>,
  366. },
  367. ];
  368. const pageClass = useMemo(() => {
  369. const classes = ['groups-page'];
  370. if (isDark) classes.push('is-dark');
  371. if (isUltra) classes.push('is-ultra');
  372. return classes.join(' ');
  373. }, [isDark, isUltra]);
  374. return (
  375. <ConfigProvider theme={antdThemeConfig}>
  376. {messageContextHolder}
  377. {modalContextHolder}
  378. <Layout className={pageClass}>
  379. <AppSidebar />
  380. <Layout className="content-shell">
  381. <Layout.Content id="content-layout" className="content-area">
  382. <Spin spinning={!fetched} delay={200} description="Loading…" size="large">
  383. {!fetched ? (
  384. <div className="loading-spacer" />
  385. ) : (
  386. <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
  387. <Col span={24}>
  388. <Card size="small" hoverable className="summary-card">
  389. <Row gutter={[16, isMobile ? 16 : 12]}>
  390. <Col xs={12} sm={8} md={6}>
  391. <Statistic
  392. title={t('pages.groups.totalGroups')}
  393. value={String(totalGroups)}
  394. prefix={<TagsOutlined />}
  395. />
  396. </Col>
  397. <Col xs={12} sm={8} md={6}>
  398. <Statistic
  399. title={t('pages.groups.totalGroupedClients')}
  400. value={String(totalClients)}
  401. prefix={<TeamOutlined />}
  402. />
  403. </Col>
  404. <Col xs={12} sm={8} md={6}>
  405. <Statistic
  406. title={t('pages.groups.emptyGroups')}
  407. value={String(emptyGroups)}
  408. />
  409. </Col>
  410. </Row>
  411. </Card>
  412. </Col>
  413. <Col span={24}>
  414. <Card
  415. size="small"
  416. hoverable
  417. title={
  418. <div className="card-toolbar">
  419. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  420. {!isMobile && t('pages.groups.addGroup')}
  421. </Button>
  422. </div>
  423. }
  424. >
  425. <Table<GroupSummary>
  426. dataSource={groups}
  427. columns={columns}
  428. rowKey="name"
  429. size="small"
  430. pagination={false}
  431. loading={loading}
  432. locale={{
  433. emptyText: (
  434. <div className="card-empty">
  435. <TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  436. <div>{t('noData')}</div>
  437. </div>
  438. ),
  439. }}
  440. />
  441. </Card>
  442. </Col>
  443. </Row>
  444. )}
  445. </Spin>
  446. </Layout.Content>
  447. </Layout>
  448. <Modal
  449. open={createOpen}
  450. title={t('pages.groups.addGroup')}
  451. okText={t('create')}
  452. cancelText={t('cancel')}
  453. confirmLoading={createMut.isPending}
  454. onCancel={() => setCreateOpen(false)}
  455. onOk={confirmCreate}
  456. destroyOnHidden
  457. >
  458. <Form layout="vertical">
  459. <Form.Item label={t('pages.groups.name')}>
  460. <Input
  461. value={createName}
  462. onChange={(e) => setCreateName(e.target.value)}
  463. onPressEnter={confirmCreate}
  464. placeholder={t('pages.clients.groupPlaceholder')}
  465. autoFocus
  466. />
  467. </Form.Item>
  468. </Form>
  469. </Modal>
  470. <Modal
  471. open={renameOpen}
  472. title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
  473. okText={t('save')}
  474. cancelText={t('cancel')}
  475. confirmLoading={renameMut.isPending}
  476. onCancel={() => setRenameOpen(false)}
  477. onOk={confirmRename}
  478. destroyOnHidden
  479. >
  480. <Form layout="vertical">
  481. <Form.Item label={t('pages.groups.name')}>
  482. <Input
  483. value={renameValue}
  484. onChange={(e) => setRenameValue(e.target.value)}
  485. onPressEnter={confirmRename}
  486. placeholder={t('pages.clients.groupPlaceholder')}
  487. autoFocus
  488. />
  489. </Form.Item>
  490. </Form>
  491. </Modal>
  492. <LazyMount when={subLinksOpen}>
  493. <SubLinksModal
  494. open={subLinksOpen}
  495. emails={groupEmails}
  496. clients={clients}
  497. subSettings={subSettings}
  498. onOpenChange={setSubLinksOpen}
  499. />
  500. </LazyMount>
  501. <LazyMount when={adjustOpen}>
  502. <ClientBulkAdjustModal
  503. open={adjustOpen}
  504. count={groupEmails.length}
  505. onOpenChange={setAdjustOpen}
  506. onSubmit={async (addDays, addBytes) => {
  507. const msg = await bulkAdjust(groupEmails, addDays, addBytes);
  508. if (msg?.success) {
  509. const obj = msg.obj ?? { adjusted: 0 };
  510. messageApi.success(
  511. t('pages.groups.adjustSuccess', {
  512. count: obj.adjusted ?? 0,
  513. name: groupForAction?.name ?? '',
  514. }),
  515. );
  516. return obj;
  517. }
  518. return null;
  519. }}
  520. />
  521. </LazyMount>
  522. <LazyMount when={addClientsOpen}>
  523. <GroupAddClientsModal
  524. open={addClientsOpen}
  525. groupName={groupForAction?.name ?? null}
  526. candidates={clients.filter((c) => c.group !== groupForAction?.name)}
  527. onClose={() => setAddClientsOpen(false)}
  528. onSubmit={async (emails) => {
  529. const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
  530. if (msg?.success) {
  531. return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
  532. }
  533. return null;
  534. }}
  535. />
  536. </LazyMount>
  537. <LazyMount when={removeClientsOpen}>
  538. <GroupRemoveClientsModal
  539. open={removeClientsOpen}
  540. groupName={groupForAction?.name ?? null}
  541. members={clients.filter((c) => c.group === groupForAction?.name)}
  542. onClose={() => setRemoveClientsOpen(false)}
  543. onSubmit={async (emails) => {
  544. const msg = await bulkRemoveFromGroup(emails);
  545. if (msg?.success) {
  546. return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
  547. }
  548. return null;
  549. }}
  550. />
  551. </LazyMount>
  552. </Layout>
  553. </ConfigProvider>
  554. );
  555. }