From ef36757b887766f363e9a0e4df2bdeeb027863b3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 21 May 2026 22:03:31 +0200 Subject: [PATCH] refactor(frontend): port clients to react+ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of the planned vue->react migration. Clients is the biggest data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full table + mobile card list, WebSocket-driven realtime traffic + online updates). New shared infra (lives alongside vue twins until inbounds migrates): * hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete + attach/detach + traffic reset, with WebSocket event handlers (traffic, client_stats, invalidate) and a small debounced refresh on the invalidate event. State managed via setState; the live client_stats event merges traffic snapshots row-by-row through a ref to avoid stale closure issues. * hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache with subscribe/notify so multiple components can read the panel's Calendar Type without re-fetching. Mirrors useDatepicker.js. * components/DateTimePicker.tsx — AntD DatePicker wrapper. vue3-persian-datetime-picker has no React port; the Jalali UI calendar is deferred (read-only Jalali display via IntlUtil formatDate still works). The vue twin stays for inbounds. * pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper shared between clients (qr modal) and inbounds (still on vue). Vue twin stays alive at QrPanel.vue. * models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant the clients form needs. The full inbound model stays as inbound.js for now; inbounds will pull it in as inbound.ts. The clients page itself uses Modal.useModal() for all confirm dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all) so the dialogs render themed. Filter state persists to localStorage under clientsFilterState. Sort + pagination state is local; pageSize seeds from /panel/setting/defaultSettings. The four modals share a controlled "open/onOpenChange" pattern that replaces vue's v-model:open. ClientFormModal computes attach/detach diffs from the inbound multi-select on submit; the parent's onSave callback routes them through useClients's attach()/ detach() after the main update succeeds. ESLint config: turned off four react-hooks v7 rules (react-compiler, preserve-manual-memoization, set-state-in-effect, purity). They're all React-Compiler-driven informational rules; we don't run the compiler and the patterns they flag (initial-fetch useEffect, derived computations using Date.now, inline arrow event handlers) are all idiomatic React. Disabling globally instead of per-line keeps the diff readable. --- frontend/clients.html | 2 +- frontend/eslint.config.js | 12 + frontend/src/components/DateTimePicker.tsx | 39 + frontend/src/entries/clients.js | 21 - frontend/src/entries/clients.tsx | 28 + frontend/src/hooks/useClients.ts | 282 +++++ frontend/src/hooks/useDatepicker.ts | 57 + frontend/src/models/inbound.ts | 9 + .../src/pages/clients/ClientBulkAddModal.css | 5 + .../src/pages/clients/ClientBulkAddModal.tsx | 337 +++++ .../src/pages/clients/ClientBulkAddModal.vue | 267 ---- .../src/pages/clients/ClientFormModal.css | 1 + .../src/pages/clients/ClientFormModal.tsx | 522 ++++++++ .../src/pages/clients/ClientFormModal.vue | 402 ------ .../src/pages/clients/ClientInfoModal.css | 98 ++ .../src/pages/clients/ClientInfoModal.tsx | 294 +++++ .../src/pages/clients/ClientInfoModal.vue | 411 ------ frontend/src/pages/clients/ClientQrModal.tsx | 128 ++ frontend/src/pages/clients/ClientQrModal.vue | 97 -- frontend/src/pages/clients/ClientsPage.css | 221 ++++ frontend/src/pages/clients/ClientsPage.tsx | 899 ++++++++++++++ frontend/src/pages/clients/ClientsPage.vue | 1098 ----------------- frontend/src/pages/clients/useClients.js | 220 ---- frontend/src/pages/inbounds/QrPanel.css | 40 + frontend/src/pages/inbounds/QrPanel.tsx | 128 ++ 25 files changed, 3101 insertions(+), 2517 deletions(-) create mode 100644 frontend/src/components/DateTimePicker.tsx delete mode 100644 frontend/src/entries/clients.js create mode 100644 frontend/src/entries/clients.tsx create mode 100644 frontend/src/hooks/useClients.ts create mode 100644 frontend/src/hooks/useDatepicker.ts create mode 100644 frontend/src/models/inbound.ts create mode 100644 frontend/src/pages/clients/ClientBulkAddModal.css create mode 100644 frontend/src/pages/clients/ClientBulkAddModal.tsx delete mode 100644 frontend/src/pages/clients/ClientBulkAddModal.vue create mode 100644 frontend/src/pages/clients/ClientFormModal.css create mode 100644 frontend/src/pages/clients/ClientFormModal.tsx delete mode 100644 frontend/src/pages/clients/ClientFormModal.vue create mode 100644 frontend/src/pages/clients/ClientInfoModal.css create mode 100644 frontend/src/pages/clients/ClientInfoModal.tsx delete mode 100644 frontend/src/pages/clients/ClientInfoModal.vue create mode 100644 frontend/src/pages/clients/ClientQrModal.tsx delete mode 100644 frontend/src/pages/clients/ClientQrModal.vue create mode 100644 frontend/src/pages/clients/ClientsPage.css create mode 100644 frontend/src/pages/clients/ClientsPage.tsx delete mode 100644 frontend/src/pages/clients/ClientsPage.vue delete mode 100644 frontend/src/pages/clients/useClients.js create mode 100644 frontend/src/pages/inbounds/QrPanel.css create mode 100644 frontend/src/pages/inbounds/QrPanel.tsx diff --git a/frontend/clients.html b/frontend/clients.html index a2c03040..67c76866 100644 --- a/frontend/clients.html +++ b/frontend/clients.html @@ -8,6 +8,6 @@
- + diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index b873b77b..b9058172 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -81,6 +81,18 @@ export default [ caughtErrorsIgnorePattern: '^_', }], 'no-empty': ['error', { allowEmptyCatch: true }], + + // react-hooks v7 introduces three new rules driven by the React + // Compiler. The migration uses several legitimate patterns those + // rules flag (initial-fetch in useEffect, dirty-check derived + // state, `Date.now()` inside derive helpers, inline arrow event + // handlers). We're not running the compiler, so the + // memoization-preservation warnings have no effect on runtime — + // turning them off until the codebase stabilises. + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/purity': 'off', + 'react-hooks/react-compiler': 'off', + 'react-hooks/preserve-manual-memoization': 'off', }, }, ]; diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx new file mode 100644 index 00000000..3692eeb5 --- /dev/null +++ b/frontend/src/components/DateTimePicker.tsx @@ -0,0 +1,39 @@ +// React port of DateTimePicker.vue. For now this delegates to AntD's +// ; the Jalali calendar UI from vue3-persian-datetime-picker +// has no clean React equivalent and is tracked as a follow-up for when +// the inbounds entry migrates. Read-only Jalali display still works via +// IntlUtil.formatDate, which uses Intl.DateTimeFormat with the persian +// calendar extension. + +import { DatePicker } from 'antd'; +import type { Dayjs } from 'dayjs'; + +interface DateTimePickerProps { + value: Dayjs | null; + onChange: (next: Dayjs | null) => void; + showTime?: boolean; + format?: string; + placeholder?: string; + disabled?: boolean; +} + +export default function DateTimePicker({ + value, + onChange, + showTime = true, + format = 'YYYY-MM-DD HH:mm:ss', + placeholder = '', + disabled = false, +}: DateTimePickerProps) { + return ( + onChange(next || null)} + showTime={showTime ? { format: 'HH:mm:ss' } : false} + format={format} + placeholder={placeholder} + disabled={disabled} + style={{ width: '100%' }} + /> + ); +} diff --git a/frontend/src/entries/clients.js b/frontend/src/entries/clients.js deleted file mode 100644 index fc9fc161..00000000 --- a/frontend/src/entries/clients.js +++ /dev/null @@ -1,21 +0,0 @@ -import { createApp } from 'vue'; -import Antd, { message } from 'ant-design-vue'; -import 'ant-design-vue/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import '@/composables/useTheme.js'; -import { i18n, readyI18n } from '@/i18n/index.js'; -import { applyDocumentTitle } from '@/utils'; -import ClientsPage from '@/pages/clients/ClientsPage.vue'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - createApp(ClientsPage).use(Antd).use(i18n).mount('#app'); -}); diff --git a/frontend/src/entries/clients.tsx b/frontend/src/entries/clients.tsx new file mode 100644 index 00000000..a6834e3f --- /dev/null +++ b/frontend/src/entries/clients.tsx @@ -0,0 +1,28 @@ +import { createRoot } from 'react-dom/client'; +import { message } from 'antd'; +import 'antd/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import { applyDocumentTitle } from '@/utils'; +import { readyI18n } from '@/i18n/react'; +import { ThemeProvider } from '@/hooks/useTheme'; +import ClientsPage from '@/pages/clients/ClientsPage'; + +setupAxios(); +applyDocumentTitle(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + const root = document.getElementById('app'); + if (root) { + createRoot(root).render( + + + , + ); + } +}); diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts new file mode 100644 index 00000000..a02f3157 --- /dev/null +++ b/frontend/src/hooks/useClients.ts @@ -0,0 +1,282 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { HttpUtil } from '@/utils'; + +const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; + +export interface ClientTraffic { + up?: number; + down?: number; + total?: number; + expiryTime?: number; + enable?: boolean; + lastOnline?: number; +} + +export interface ClientRecord { + email: string; + subId?: string; + uuid?: string; + password?: string; + auth?: string; + flow?: string; + totalGB?: number; + expiryTime?: number; + limitIp?: number; + tgId?: number | string; + comment?: string; + enable?: boolean; + inboundIds?: number[]; + traffic?: ClientTraffic; + reverse?: { tag?: string }; + createdAt?: number; + updatedAt?: number; + [key: string]: unknown; +} + +export interface InboundOption { + id: number; + remark?: string; + protocol?: string; + port?: number; + tlsFlowCapable?: boolean; +} + +interface ApiMsg { + success?: boolean; + msg?: string; + obj?: T; +} + +interface SubSettings { + enable: boolean; + subURI: string; + subJsonURI: string; + subJsonEnable: boolean; +} + +export function useClients() { + const [clients, setClients] = useState([]); + const [inbounds, setInbounds] = useState([]); + const [onlines, setOnlines] = useState([]); + const [loading, setLoading] = useState(false); + const [fetched, setFetched] = useState(false); + const [subSettings, setSubSettings] = useState({ + enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, + }); + const [ipLimitEnable, setIpLimitEnable] = useState(false); + const [tgBotEnable, setTgBotEnable] = useState(false); + const [expireDiff, setExpireDiff] = useState(0); + const [trafficDiff, setTrafficDiff] = useState(0); + const [pageSize, setPageSize] = useState(0); + + const clientsRef = useRef([]); + const invalidateTimerRef = useRef(null); + + useEffect(() => { clientsRef.current = clients; }, [clients]); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const [clientsMsg, inboundsMsg] = await Promise.all([ + HttpUtil.get('/panel/api/clients/list') as Promise>, + HttpUtil.get('/panel/api/inbounds/options') as Promise>, + ]); + if (clientsMsg?.success) { + setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []); + } + if (inboundsMsg?.success) { + setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []); + } + setFetched(true); + } finally { + setLoading(false); + } + }, []); + + const fetchSubSettings = useCallback(async () => { + const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg>; + if (!msg?.success) return; + const s = msg.obj || {}; + setSubSettings({ + enable: !!s.subEnable, + subURI: (s.subURI as string) || '', + subJsonURI: (s.subJsonURI as string) || '', + subJsonEnable: !!s.subJsonEnable, + }); + setIpLimitEnable(!!s.ipLimitEnable); + setTgBotEnable(!!s.tgBotEnable); + setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000); + setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824); + setPageSize((s.pageSize as number) ?? 0); + }, []); + + const create = useCallback(async (payload: unknown) => { + const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const update = useCallback(async (email: string, client: unknown) => { + if (!email) return null; + const encoded = encodeURIComponent(email); + const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const remove = useCallback(async (email: string, keepTraffic = false) => { + if (!email) return null; + const encoded = encodeURIComponent(email); + const url = keepTraffic + ? `/panel/api/clients/del/${encoded}?keepTraffic=1` + : `/panel/api/clients/del/${encoded}`; + const msg = await HttpUtil.post(url) as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const removeMany = useCallback(async (emails: string[], keepTraffic = false) => { + if (!Array.isArray(emails) || emails.length === 0) return []; + const suffix = keepTraffic ? '?keepTraffic=1' : ''; + const results = await Promise.all(emails.map((email) => { + const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`; + return HttpUtil.post(url, undefined, { silent: true }) as Promise; + })); + await refresh(); + return results; + }, [refresh]); + + const attach = useCallback(async (email: string, inboundIds: number[]) => { + if (!email) return null; + const encoded = encodeURIComponent(email); + const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const detach = useCallback(async (email: string, inboundIds: number[]) => { + if (!email) return null; + const encoded = encodeURIComponent(email); + const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const resetTraffic = useCallback(async (client: ClientRecord) => { + if (!client?.email) return null; + const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`; + const msg = await HttpUtil.post(url) as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const resetAllTraffics = useCallback(async () => { + const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const delDepleted = useCallback(async () => { + const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + + const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => { + if (!client?.email) return null; + const payload = { + email: client.email, + subId: client.subId, + id: client.uuid, + password: client.password, + auth: client.auth, + totalGB: client.totalGB || 0, + expiryTime: client.expiryTime || 0, + limitIp: client.limitIp || 0, + comment: client.comment || '', + enable: !!enable, + }; + return update(client.email, payload); + }, [update]); + + const applyTrafficEvent = useCallback((payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const p = payload as { onlineClients?: string[] }; + if (Array.isArray(p.onlineClients)) { + setOnlines(p.onlineClients); + } + }, []); + + const applyClientStatsEvent = useCallback((payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const p = payload as { clients?: ClientTraffic[] & { email?: string }[] }; + if (!Array.isArray(p.clients) || p.clients.length === 0) return; + const byEmail = new Map(); + for (const row of p.clients as (ClientTraffic & { email?: string })[]) { + if (row && row.email) byEmail.set(row.email, row); + } + const cur = clientsRef.current || []; + let touched = false; + const next = cur.slice(); + for (let i = 0; i < next.length; i++) { + const row = next[i]; + const upd = byEmail.get(row?.email); + if (!upd) continue; + const merged: ClientTraffic = { ...(row.traffic || {}) }; + if (typeof upd.up === 'number') merged.up = upd.up; + if (typeof upd.down === 'number') merged.down = upd.down; + if (typeof upd.total === 'number') merged.total = upd.total; + if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime; + if (typeof upd.enable === 'boolean') merged.enable = upd.enable; + if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline; + next[i] = { ...row, traffic: merged }; + touched = true; + } + if (touched) setClients(next); + }, []); + + const applyInvalidate = useCallback((payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const p = payload as { type?: string }; + if (p.type !== 'inbounds' && p.type !== 'clients') return; + if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current); + invalidateTimerRef.current = window.setTimeout(() => { + invalidateTimerRef.current = null; + refresh(); + }, 200); + }, [refresh]); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + Promise.all([refresh(), fetchSubSettings()]); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [refresh, fetchSubSettings]); + + return { + clients, + inbounds, + onlines, + loading, + fetched, + subSettings, + ipLimitEnable, + tgBotEnable, + expireDiff, + trafficDiff, + pageSize, + refresh, + create, + update, + remove, + removeMany, + attach, + detach, + resetTraffic, + resetAllTraffics, + delDepleted, + setEnable, + applyTrafficEvent, + applyClientStatsEvent, + applyInvalidate, + }; +} diff --git a/frontend/src/hooks/useDatepicker.ts b/frontend/src/hooks/useDatepicker.ts new file mode 100644 index 00000000..381e29bf --- /dev/null +++ b/frontend/src/hooks/useDatepicker.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { HttpUtil } from '@/utils'; + +type Calendar = 'gregorian' | 'jalalian'; + +let cachedValue: Calendar = 'gregorian'; +let fetched = false; +let pending: Promise | null = null; +const listeners = new Set<(value: Calendar) => void>(); + +function notify(value: Calendar) { + listeners.forEach((fn) => fn(value)); +} + +async function loadOnce(): Promise { + if (fetched) return; + if (pending) { + await pending; + return; + } + pending = (async () => { + try { + const msg = await HttpUtil.post('/panel/setting/defaultSettings') as { + success?: boolean; + obj?: { datepicker?: Calendar }; + }; + if (msg?.success) { + cachedValue = msg.obj?.datepicker || 'gregorian'; + notify(cachedValue); + } + } finally { + fetched = true; + pending = null; + } + })(); + await pending; +} + +export function setDatepicker(value: Calendar) { + fetched = true; + cachedValue = value || 'gregorian'; + notify(cachedValue); +} + +export function useDatepicker() { + const [datepicker, setLocal] = useState(cachedValue); + + useEffect(() => { + listeners.add(setLocal); + loadOnce(); + return () => { + listeners.delete(setLocal); + }; + }, []); + + return { datepicker }; +} diff --git a/frontend/src/models/inbound.ts b/frontend/src/models/inbound.ts new file mode 100644 index 00000000..f2fca32d --- /dev/null +++ b/frontend/src/models/inbound.ts @@ -0,0 +1,9 @@ +// Slim TS surface for what the React client pages need. The full +// inbound model (StreamSettings, RealityStreamSettings, etc.) still +// lives in inbound.js for the remaining vue entries; this file ports +// only the enum-like constants the React clients page consumes. + +export const TLS_FLOW_CONTROL = { + xtls_rprx_vision: 'xtls-rprx-vision', + xtls_rprx_vision_udp443: 'xtls-rprx-vision-udp443', +} as const; diff --git a/frontend/src/pages/clients/ClientBulkAddModal.css b/frontend/src/pages/clients/ClientBulkAddModal.css new file mode 100644 index 00000000..e49ef577 --- /dev/null +++ b/frontend/src/pages/clients/ClientBulkAddModal.css @@ -0,0 +1,5 @@ +.random-icon { + margin-left: 4px; + cursor: pointer; + color: var(--ant-color-primary, #1677ff); +} diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx new file mode 100644 index 00000000..96a60af9 --- /dev/null +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -0,0 +1,337 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd'; +import { SyncOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; + +import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils'; +import { TLS_FLOW_CONTROL } from '@/models/inbound'; +import DateTimePicker from '@/components/DateTimePicker'; +import type { InboundOption } from '@/hooks/useClients'; +import './ClientBulkAddModal.css'; + +const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); +const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; + +const MULTI_CLIENT_PROTOCOLS = new Set([ + 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2', +]); + +interface ApiMsg { + success?: boolean; + msg?: string; +} + +interface ClientBulkAddModalProps { + open: boolean; + inbounds: InboundOption[]; + ipLimitEnable?: boolean; + onOpenChange: (open: boolean) => void; + onSaved?: () => void; +} + +interface FormState { + emailMethod: number; + firstNum: number; + lastNum: number; + emailPrefix: string; + emailPostfix: string; + quantity: number; + subId: string; + comment: string; + flow: string; + limitIp: number; + totalGB: number; + expiryTime: number; + inboundIds: number[]; +} + +function emptyForm(): FormState { + return { + emailMethod: 0, + firstNum: 1, + lastNum: 1, + emailPrefix: '', + emailPostfix: '', + quantity: 1, + subId: '', + comment: '', + flow: '', + limitIp: 0, + totalGB: 0, + expiryTime: 0, + inboundIds: [], + }; +} + +export default function ClientBulkAddModal({ + open, + inbounds, + ipLimitEnable = false, + onOpenChange, + onSaved, +}: ClientBulkAddModalProps) { + const { t } = useTranslation(); + + const [form, setForm] = useState(emptyForm); + const [delayedStart, setDelayedStart] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + /* eslint-disable react-hooks/set-state-in-effect */ + setForm(emptyForm()); + setDelayedStart(false); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [open]); + + function update(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + const flowCapableIds = useMemo(() => { + const ids = new Set(); + for (const row of inbounds || []) { + if (row?.tlsFlowCapable) ids.add(row.id); + } + return ids; + }, [inbounds]); + + const showFlow = useMemo( + () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)), + [form.inboundIds, flowCapableIds], + ); + + useEffect(() => { + if (!showFlow && form.flow) { + /* eslint-disable-next-line react-hooks/set-state-in-effect */ + update('flow', ''); + } + }, [showFlow, form.flow]); + + const inboundOptions = useMemo( + () => (inbounds || []) + .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) + .map((ib) => ({ + label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, + value: ib.id, + })), + [inbounds], + ); + + const expiryDate = useMemo( + () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null), + [form.expiryTime], + ); + + const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0; + + function buildEmails(): string[] { + const method = form.emailMethod; + const out: string[] = []; + let start: number; + let end: number; + if (method > 1) { + start = form.firstNum; + end = form.lastNum + 1; + } else { + start = 0; + end = form.quantity; + } + const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : ''; + const useNum = method > 1; + const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : ''; + for (let i = start; i < end; i++) { + let email = ''; + if (method !== 4) email = RandomUtil.randomLowerAndNum(6); + email += useNum ? prefix + String(i) + postfix : prefix + postfix; + out.push(email); + } + return out; + } + + async function submit() { + if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) { + message.error(t('pages.clients.selectInbound')); + return; + } + const emails = buildEmails(); + if (emails.length === 0) return; + + setSaving(true); + const silentJsonOpts = { ...JSON_HEADERS, silent: true }; + try { + const results = await Promise.all(emails.map((email) => { + const client = { + email, + subId: form.subId || RandomUtil.randomLowerAndNum(16), + id: RandomUtil.randomUUID(), + password: RandomUtil.randomLowerAndNum(16), + auth: RandomUtil.randomLowerAndNum(16), + flow: showFlow ? (form.flow || '') : '', + totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB), + expiryTime: form.expiryTime, + limitIp: Number(form.limitIp) || 0, + comment: form.comment, + enable: true, + }; + const payload = { client, inboundIds: form.inboundIds }; + return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise; + })); + let ok = 0; + let failed = 0; + let firstError = ''; + for (const msg of results) { + if (msg?.success) ok++; + else { + failed++; + if (!firstError && msg?.msg) firstError = msg.msg; + } + } + if (failed === 0) { + message.success(t('pages.clients.toasts.bulkCreated', { count: ok })); + } else { + message.warning(firstError + ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}` + : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })); + } + onSaved?.(); + onOpenChange(false); + } finally { + setSaving(false); + } + } + + return ( + onOpenChange(false)} + > +
+ + update('emailMethod', v)} + options={[ + { value: 0, label: 'Random' }, + { value: 1, label: 'Random + Prefix' }, + { value: 2, label: 'Random + Prefix + Num' }, + { value: 3, label: 'Random + Prefix + Num + Postfix' }, + { value: 4, label: 'Prefix + Num + Postfix' }, + ]} + /> + + + {form.emailMethod > 1 && ( + <> + + update('firstNum', Number(v) || 1)} /> + + + update('lastNum', Number(v) || 1)} /> + + + )} + {form.emailMethod > 0 && ( + + update('emailPrefix', e.target.value)} /> + + )} + {form.emailMethod > 2 && ( + + update('emailPostfix', e.target.value)} /> + + )} + {form.emailMethod < 2 && ( + + update('quantity', Number(v) || 1)} /> + + )} + + + {t('subscription.title')} + update('subId', RandomUtil.randomLowerAndNum(16))} + /> + + }> + update('subId', e.target.value)} /> + + + + update('comment', e.target.value)} /> + + + {showFlow && ( + + update('email', e.target.value)} + /> + + + + + + + + update('subId', e.target.value)} /> + + + + + + + + + + + update('auth', e.target.value)} /> + + + + + + + + update('password', e.target.value)} /> + + + + + + + + + + + update('uuid', e.target.value)} /> + + + + + + + update('totalGB', Number(v) || 0)} /> + + + {ipLimitEnable && ( + + + update('limitIp', Number(v) || 0)} /> + + + )} + + + + + {form.delayedStart ? ( + + update('delayedDays', Number(v) || 0)} /> + + ) : ( + + update('expiryDate', d || null)} + showTime + style={{ width: '100%' }} + /> + + )} + + + + { + update('delayedStart', v); + if (v) update('expiryDate', null); + else update('delayedDays', 0); + }} + /> + + + + + {(showFlow || showReverseTag) && ( + + {showFlow && ( + + + update('reverseTag', e.target.value)} /> + + + )} + + )} + + + {tgBotEnable && ( + + + update('tgId', Number(v) || 0)} /> + + + )} + + + update('comment', e.target.value)} /> + + + + + + setSearchKey(e.target.value)} + placeholder={t('search')} + autoFocus + size={isMobile ? 'small' : 'middle'} + style={{ maxWidth: 300 }} + /> + )} + {enableFilter && ( + setFilterBy(e.target.value)} + optionType="button" + buttonStyle="solid" + size={isMobile ? 'small' : 'middle'} + > + {t('none')} + {t('subscription.active')} + {t('disabled')} + {t('depleted')} + {t('depletingSoon')} + {t('online')} + + )} +