ClientBulkAddModal.tsx 12 KB

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