ClientBulkAddModal.tsx 10 KB

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