ClientBulkAddModal.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  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 { RandomUtil, SizeFormatter } from '@/utils';
  8. import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
  9. import DateTimePicker from '@/components/DateTimePicker';
  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. onOpenChange: (open: boolean) => void;
  21. onSaved?: () => void;
  22. }
  23. type FormState = ClientBulkAddFormValues;
  24. function emptyForm(): FormState {
  25. return {
  26. emailMethod: 0,
  27. firstNum: 1,
  28. lastNum: 1,
  29. emailPrefix: '',
  30. emailPostfix: '',
  31. quantity: 1,
  32. subId: '',
  33. comment: '',
  34. flow: '',
  35. limitIp: 0,
  36. totalGB: 0,
  37. expiryTime: 0,
  38. inboundIds: [],
  39. };
  40. }
  41. export default function ClientBulkAddModal({
  42. open,
  43. inbounds,
  44. ipLimitEnable = false,
  45. onOpenChange,
  46. onSaved,
  47. }: ClientBulkAddModalProps) {
  48. const { t } = useTranslation();
  49. const [messageApi, messageContextHolder] = message.useMessage();
  50. const { bulkCreate } = useClients();
  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. try {
  125. const payloads = emails.map((email) => ({
  126. client: {
  127. email,
  128. subId: form.subId || RandomUtil.randomLowerAndNum(16),
  129. id: RandomUtil.randomUUID(),
  130. password: RandomUtil.randomLowerAndNum(16),
  131. auth: RandomUtil.randomLowerAndNum(16),
  132. flow: showFlow ? (form.flow || '') : '',
  133. totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
  134. expiryTime: form.expiryTime,
  135. limitIp: Number(form.limitIp) || 0,
  136. comment: form.comment,
  137. enable: true,
  138. },
  139. inboundIds: form.inboundIds,
  140. }));
  141. const msg = await bulkCreate(payloads);
  142. const ok = msg?.obj?.created ?? 0;
  143. const skipped = msg?.obj?.skipped ?? [];
  144. const failed = skipped.length;
  145. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  146. if (failed === 0 && msg?.success) {
  147. messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
  148. } else {
  149. messageApi.warning(firstError
  150. ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
  151. : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
  152. }
  153. onSaved?.();
  154. onOpenChange(false);
  155. } finally {
  156. setSaving(false);
  157. }
  158. }
  159. return (
  160. <>
  161. {messageContextHolder}
  162. <Modal
  163. open={open}
  164. title={t('pages.clients.bulk')}
  165. okText={t('create')}
  166. cancelText={t('close')}
  167. confirmLoading={saving}
  168. mask={{ closable: false }}
  169. width={640}
  170. onOk={submit}
  171. onCancel={() => onOpenChange(false)}
  172. >
  173. <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
  174. <Form.Item label={t('pages.clients.attachedInbounds')} required>
  175. <Select
  176. mode="multiple"
  177. value={form.inboundIds}
  178. onChange={(v) => update('inboundIds', v)}
  179. options={inboundOptions}
  180. placeholder={t('pages.clients.selectInbound')}
  181. showSearch
  182. filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
  183. />
  184. </Form.Item>
  185. <Form.Item label={t('pages.clients.method')}>
  186. <Select
  187. value={form.emailMethod}
  188. onChange={(v) => update('emailMethod', v)}
  189. options={[
  190. { value: 0, label: 'Random' },
  191. { value: 1, label: 'Random + Prefix' },
  192. { value: 2, label: 'Random + Prefix + Num' },
  193. { value: 3, label: 'Random + Prefix + Num + Postfix' },
  194. { value: 4, label: 'Prefix + Num + Postfix' },
  195. ]}
  196. />
  197. </Form.Item>
  198. {form.emailMethod > 1 && (
  199. <>
  200. <Form.Item label={t('pages.clients.first')}>
  201. <InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
  202. </Form.Item>
  203. <Form.Item label={t('pages.clients.last')}>
  204. <InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
  205. </Form.Item>
  206. </>
  207. )}
  208. {form.emailMethod > 0 && (
  209. <Form.Item label={t('pages.clients.prefix')}>
  210. <Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
  211. </Form.Item>
  212. )}
  213. {form.emailMethod > 2 && (
  214. <Form.Item label={t('pages.clients.postfix')}>
  215. <Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
  216. </Form.Item>
  217. )}
  218. {form.emailMethod < 2 && (
  219. <Form.Item label={t('pages.clients.clientCount')}>
  220. <InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
  221. </Form.Item>
  222. )}
  223. <Form.Item label={
  224. <>
  225. {t('subscription.title')}
  226. <SyncOutlined
  227. className="random-icon"
  228. onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
  229. />
  230. </>
  231. }>
  232. <Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
  233. </Form.Item>
  234. <Form.Item label={t('comment')}>
  235. <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
  236. </Form.Item>
  237. {showFlow && (
  238. <Form.Item label={t('pages.clients.flow')}>
  239. <Select
  240. value={form.flow}
  241. onChange={(v) => update('flow', v)}
  242. style={{ width: 220 }}
  243. options={[
  244. { value: '', label: t('none') },
  245. ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
  246. ]}
  247. />
  248. </Form.Item>
  249. )}
  250. {ipLimitEnable && (
  251. <Form.Item label={t('pages.clients.limitIp')}>
  252. <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
  253. </Form.Item>
  254. )}
  255. <Form.Item label={t('pages.clients.totalGB')}>
  256. <InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
  257. </Form.Item>
  258. <Form.Item label={t('pages.clients.delayedStart')}>
  259. <Switch
  260. checked={delayedStart}
  261. onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
  262. />
  263. </Form.Item>
  264. {delayedStart ? (
  265. <Form.Item label={t('pages.clients.expireDays')}>
  266. <InputNumber
  267. value={delayedExpireDays}
  268. min={0}
  269. onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
  270. />
  271. </Form.Item>
  272. ) : (
  273. <Form.Item label={t('pages.inbounds.expireDate')}>
  274. <DateTimePicker
  275. value={expiryDate}
  276. onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
  277. />
  278. </Form.Item>
  279. )}
  280. </Form>
  281. </Modal>
  282. </>
  283. );
  284. }