ClientBulkAddModal.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tooltip, message } from 'antd';
  4. import { ReloadOutlined } from '@ant-design/icons';
  5. import dayjs from 'dayjs';
  6. import type { Dayjs } from 'dayjs';
  7. import { FormProvider, useForm, useWatch } from 'react-hook-form';
  8. import { RandomUtil, SizeFormatter } from '@/utils';
  9. import { formatInboundLabel } from '@/lib/inbounds/label';
  10. import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
  11. import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
  12. import { FormField } from '@/components/form/rhf';
  13. import { useClients, type InboundOption } from '@/hooks/useClients';
  14. import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
  15. import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
  16. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  17. const MULTI_CLIENT_PROTOCOLS = new Set([
  18. 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
  19. ]);
  20. const EMPTY: ClientBulkAddFormValues = {
  21. emailMethod: 0,
  22. firstNum: 1,
  23. lastNum: 1,
  24. emailPrefix: '',
  25. emailPostfix: '',
  26. quantity: 1,
  27. subId: '',
  28. group: '',
  29. comment: '',
  30. flow: '',
  31. limitIp: 0,
  32. limitHwid: 0,
  33. totalGB: 0,
  34. expiryTime: 0,
  35. reset: 0,
  36. inboundIds: [],
  37. };
  38. interface ClientBulkAddModalProps {
  39. open: boolean;
  40. inbounds: InboundOption[];
  41. groups?: string[];
  42. onOpenChange: (open: boolean) => void;
  43. onSaved?: () => void;
  44. }
  45. export default function ClientBulkAddModal({
  46. open,
  47. inbounds,
  48. groups = [],
  49. onOpenChange,
  50. onSaved,
  51. }: ClientBulkAddModalProps) {
  52. const { t } = useTranslation();
  53. const [messageApi, messageContextHolder] = message.useMessage();
  54. const { bulkCreate } = useClients({ list: false });
  55. const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
  56. const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
  57. const emailMethod = useWatch({ control: methods.control, name: 'emailMethod' });
  58. const firstNum = useWatch({ control: methods.control, name: 'firstNum' });
  59. const flow = useWatch({ control: methods.control, name: 'flow' });
  60. const expiryTime = useWatch({ control: methods.control, name: 'expiryTime' });
  61. const subId = useWatch({ control: methods.control, name: 'subId' });
  62. const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
  63. const [delayedStart, setDelayedStart] = useState(false);
  64. const [saving, setSaving] = useState(false);
  65. const fail2ban = useFail2banStatusQuery();
  66. const limitIpDisabled = !fail2ban.usable;
  67. const limitIpNotice = getLimitIpNotice(fail2ban, t);
  68. useEffect(() => {
  69. if (!open) return;
  70. methods.reset(EMPTY);
  71. setDelayedStart(false);
  72. }, [open, methods]);
  73. const flowCapableIds = useMemo(() => {
  74. const ids = new Set<number>();
  75. for (const row of inbounds || []) {
  76. if (row?.tlsFlowCapable) ids.add(row.id);
  77. }
  78. return ids;
  79. }, [inbounds]);
  80. const showFlow = useMemo(
  81. () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
  82. [inboundIds, flowCapableIds],
  83. );
  84. const ss2022Method = useMemo(() => {
  85. for (const id of inboundIds || []) {
  86. const ib = (inbounds || []).find((row) => row.id === id);
  87. const method = ib?.ssMethod;
  88. if (method && method.substring(0, 4) === '2022') return method;
  89. }
  90. return '';
  91. }, [inboundIds, inbounds]);
  92. useEffect(() => {
  93. if (!showFlow && flow) {
  94. methods.setValue('flow', '');
  95. }
  96. }, [showFlow, flow, methods]);
  97. const inboundOptions = useMemo(
  98. () => (inbounds || [])
  99. .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
  100. .map((ib) => ({
  101. label: formatInboundLabel(ib.tag, ib.remark),
  102. value: ib.id,
  103. })),
  104. [inbounds],
  105. );
  106. const expiryDate = useMemo<Dayjs | null>(
  107. () => (expiryTime > 0 ? dayjs(expiryTime) : null),
  108. [expiryTime],
  109. );
  110. const delayedExpireDays = expiryTime < 0 ? expiryTime / -86400000 : 0;
  111. function buildEmails(values: ClientBulkAddFormValues): string[] {
  112. const method = values.emailMethod;
  113. const out: string[] = [];
  114. let start: number;
  115. let end: number;
  116. if (method > 1) {
  117. start = values.firstNum;
  118. end = values.lastNum + 1;
  119. } else {
  120. start = 0;
  121. end = values.quantity;
  122. }
  123. const prefix = method > 0 && values.emailPrefix.length > 0 ? values.emailPrefix : '';
  124. const useNum = method > 1;
  125. const postfix = method > 2 && values.emailPostfix.length > 0 ? values.emailPostfix : '';
  126. for (let i = start; i < end; i++) {
  127. let email = '';
  128. if (method !== 4) email = RandomUtil.randomLowerAndNum(10);
  129. email += useNum ? prefix + String(i) + postfix : prefix + postfix;
  130. out.push(email);
  131. }
  132. return out;
  133. }
  134. async function submit() {
  135. const current = methods.getValues();
  136. const validated = ClientBulkAddFormSchema.safeParse(current);
  137. if (!validated.success) {
  138. messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
  139. return;
  140. }
  141. const emails = buildEmails(current);
  142. if (emails.length === 0) return;
  143. setSaving(true);
  144. try {
  145. const payloads = emails.map((email) => ({
  146. client: {
  147. email,
  148. subId: current.subId || RandomUtil.randomLowerAndNum(16),
  149. id: RandomUtil.randomUUID(),
  150. password: ss2022Method
  151. ? RandomUtil.randomShadowsocksPassword(ss2022Method)
  152. : RandomUtil.randomLowerAndNum(16),
  153. auth: RandomUtil.randomLowerAndNum(16),
  154. flow: showFlow ? (current.flow || '') : '',
  155. totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
  156. expiryTime: current.expiryTime,
  157. reset: Number(current.reset) || 0,
  158. limitIp: Number(current.limitIp) || 0,
  159. limitHwid: Number(current.limitHwid) || 0,
  160. group: current.group,
  161. comment: current.comment,
  162. enable: true,
  163. },
  164. inboundIds: current.inboundIds,
  165. }));
  166. const msg = await bulkCreate(payloads);
  167. const ok = msg?.obj?.created ?? 0;
  168. const skipped = msg?.obj?.skipped ?? [];
  169. const failed = skipped.length;
  170. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  171. if (failed === 0 && msg?.success) {
  172. messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
  173. } else {
  174. messageApi.warning(firstError
  175. ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
  176. : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
  177. }
  178. onSaved?.();
  179. onOpenChange(false);
  180. } finally {
  181. setSaving(false);
  182. }
  183. }
  184. return (
  185. <>
  186. {messageContextHolder}
  187. <Modal
  188. open={open}
  189. title={t('pages.clients.bulk')}
  190. okText={t('create')}
  191. cancelText={t('close')}
  192. confirmLoading={saving}
  193. mask={{ closable: false }}
  194. width={640}
  195. onOk={submit}
  196. onCancel={() => onOpenChange(false)}
  197. >
  198. <FormProvider {...methods}>
  199. <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
  200. <Form.Item label={t('pages.clients.attachedInbounds')} required>
  201. <SelectAllClearButtons
  202. options={inboundOptions}
  203. value={inboundIds}
  204. onChange={(v) => methods.setValue('inboundIds', v)}
  205. />
  206. <Select
  207. mode="multiple"
  208. value={inboundIds}
  209. onChange={(v) => methods.setValue('inboundIds', v)}
  210. options={inboundOptions}
  211. placeholder={t('pages.clients.selectInbound')}
  212. showSearch={{
  213. filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
  214. }}
  215. />
  216. </Form.Item>
  217. <FormField name="emailMethod" label={t('pages.clients.method')}>
  218. <Select
  219. options={[
  220. { value: 0, label: 'Random' },
  221. { value: 1, label: 'Random + Prefix' },
  222. { value: 2, label: 'Random + Prefix + Num' },
  223. { value: 3, label: 'Random + Prefix + Num + Postfix' },
  224. { value: 4, label: 'Prefix + Num + Postfix' },
  225. ]}
  226. />
  227. </FormField>
  228. {emailMethod > 1 && (
  229. <>
  230. <FormField name="firstNum" label={t('pages.clients.first')} transform={{ output: (v) => Number(v) || 1 }}>
  231. <InputNumber min={1} />
  232. </FormField>
  233. <FormField name="lastNum" label={t('pages.clients.last')} transform={{ output: (v) => Number(v) || 1 }}>
  234. <InputNumber min={firstNum} />
  235. </FormField>
  236. </>
  237. )}
  238. {emailMethod > 0 && (
  239. <FormField name="emailPrefix" label={t('pages.clients.prefix')}>
  240. <Input />
  241. </FormField>
  242. )}
  243. {emailMethod > 2 && (
  244. <FormField name="emailPostfix" label={t('pages.clients.postfix')}>
  245. <Input />
  246. </FormField>
  247. )}
  248. {emailMethod < 2 && (
  249. <FormField name="quantity" label={t('pages.clients.clientCount')} transform={{ output: (v) => Number(v) || 1 }}>
  250. <InputNumber min={1} max={1000} />
  251. </FormField>
  252. )}
  253. <Form.Item label={t('pages.clients.subId')}>
  254. <Space.Compact style={{ display: 'flex' }}>
  255. <Input
  256. value={subId}
  257. onChange={(e) => methods.setValue('subId', e.target.value)}
  258. style={{ flex: 1 }}
  259. />
  260. <Button
  261. aria-label={t('regenerate')}
  262. icon={<ReloadOutlined />}
  263. onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
  264. />
  265. </Space.Compact>
  266. </Form.Item>
  267. <FormField
  268. name="group"
  269. label={t('pages.clients.group')}
  270. tooltip={t('pages.clients.groupDesc')}
  271. transform={{ output: (v) => v ?? '' }}
  272. >
  273. <AutoComplete
  274. placeholder={t('pages.clients.groupPlaceholder')}
  275. options={groups.map((g) => ({ value: g }))}
  276. allowClear
  277. />
  278. </FormField>
  279. <FormField
  280. name="limitHwid"
  281. label={t('pages.clients.limitHwid')}
  282. tooltip={t('pages.clients.limitHwidDesc')}
  283. transform={{ output: (v) => Number(v) || 0 }}
  284. >
  285. <InputNumber min={0} />
  286. </FormField>
  287. <FormField name="comment" label={t('comment')}>
  288. <Input />
  289. </FormField>
  290. {showFlow && (
  291. <FormField name="flow" label={t('pages.clients.flow')}>
  292. <Select
  293. style={{ width: 220 }}
  294. options={[
  295. { value: '', label: t('none') },
  296. ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
  297. ]}
  298. />
  299. </FormField>
  300. )}
  301. <Form.Item label={t('pages.clients.limitIp')}>
  302. <Tooltip title={limitIpNotice || undefined}>
  303. <span style={{ display: 'inline-flex' }}>
  304. <InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
  305. style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
  306. onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
  307. </span>
  308. </Tooltip>
  309. </Form.Item>
  310. <FormField name="totalGB" label={t('pages.clients.totalGB')} transform={{ output: (v) => Number(v) || 0 }}>
  311. <InputNumber min={0} step={1} />
  312. </FormField>
  313. <Form.Item label={t('pages.clients.delayedStart')}>
  314. <Switch
  315. checked={delayedStart}
  316. onClick={() => { setDelayedStart(!delayedStart); methods.setValue('expiryTime', 0); }}
  317. />
  318. </Form.Item>
  319. {delayedStart ? (
  320. <Form.Item label={t('pages.clients.expireDays')}>
  321. <InputNumber
  322. value={delayedExpireDays}
  323. min={0}
  324. onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
  325. />
  326. </Form.Item>
  327. ) : (
  328. <Form.Item label={t('pages.inbounds.expireDate')}>
  329. <DateTimePicker
  330. value={expiryDate}
  331. onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
  332. />
  333. </Form.Item>
  334. )}
  335. <FormField
  336. name="reset"
  337. label={t('pages.clients.renew')}
  338. tooltip={t('pages.clients.renewDesc')}
  339. transform={{ output: (v) => Number(v) || 0 }}
  340. >
  341. <InputNumber min={0} />
  342. </FormField>
  343. </Form>
  344. </FormProvider>
  345. </Modal>
  346. </>
  347. );
  348. }