GroupsPage.tsx 17 KB

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