useClients.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { HttpUtil, Msg } from '@/utils';
  4. import { parseMsg } from '@/utils/zodValidate';
  5. import { keys } from '@/api/queryKeys';
  6. import {
  7. ClientHydrateSchema,
  8. ClientPageResponseSchema,
  9. InboundOptionsSchema,
  10. OnlinesSchema,
  11. BulkAdjustResultSchema,
  12. BulkCreateResultSchema,
  13. BulkDeleteResultSchema,
  14. DelDepletedResultSchema,
  15. type ClientHydrate,
  16. type ClientRecord,
  17. type ClientTraffic,
  18. type ClientsSummary,
  19. type ClientPageResponse,
  20. type InboundOption,
  21. type BulkAdjustResult,
  22. type BulkCreateResult,
  23. type BulkDeleteResult,
  24. } from '@/schemas/client';
  25. import { DefaultsPayloadSchema } from '@/schemas/defaults';
  26. export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
  27. const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
  28. interface SubSettings {
  29. enable: boolean;
  30. subURI: string;
  31. subJsonURI: string;
  32. subJsonEnable: boolean;
  33. subClashURI: string;
  34. subClashEnable: boolean;
  35. }
  36. export interface ClientQueryParams {
  37. page: number;
  38. pageSize: number;
  39. search?: string;
  40. // CSV strings — frontend joins arrays on ',', backend splits the same way.
  41. filter?: string;
  42. protocol?: string;
  43. inbound?: string;
  44. sort?: string;
  45. order?: 'ascend' | 'descend';
  46. expiryFrom?: number;
  47. expiryTo?: number;
  48. usageFrom?: number;
  49. usageTo?: number;
  50. autoRenew?: 'on' | 'off' | '';
  51. hasTgId?: 'yes' | 'no' | '';
  52. hasComment?: 'yes' | 'no' | '';
  53. group?: string;
  54. }
  55. const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
  56. const DEFAULT_SUMMARY: ClientsSummary = {
  57. total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
  58. };
  59. function buildQS(p: ClientQueryParams): string {
  60. const sp = new URLSearchParams();
  61. sp.set('page', String(p.page || 1));
  62. sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
  63. if (p.search) sp.set('search', p.search);
  64. if (p.filter) sp.set('filter', p.filter);
  65. if (p.protocol) sp.set('protocol', p.protocol);
  66. if (p.inbound) sp.set('inbound', p.inbound);
  67. if (p.sort) sp.set('sort', p.sort);
  68. if (p.order) sp.set('order', p.order);
  69. if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
  70. if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
  71. if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
  72. if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
  73. if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
  74. if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
  75. if (p.hasComment) sp.set('hasComment', p.hasComment);
  76. if (p.group) sp.set('group', p.group);
  77. return sp.toString();
  78. }
  79. async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
  80. const qs = buildQS(params);
  81. const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
  82. if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
  83. const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
  84. if (!validated.obj) throw new Error('Empty clients response');
  85. return validated.obj;
  86. }
  87. async function fetchInboundOptions(): Promise<InboundOption[]> {
  88. const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
  89. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
  90. const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
  91. return Array.isArray(validated.obj) ? validated.obj : [];
  92. }
  93. async function fetchDefaults(): Promise<Record<string, unknown>> {
  94. const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
  95. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
  96. const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
  97. return validated.obj || {};
  98. }
  99. export function useClients() {
  100. const queryClient = useQueryClient();
  101. const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
  102. // setQuery shallow-compares so callers can pass a fresh object every render
  103. // (the common React pattern) without triggering a re-fetch when nothing
  104. // actually changed.
  105. const setQuery = useCallback((next: ClientQueryParams) => {
  106. setQueryState((prev) => {
  107. if (
  108. prev.page === next.page
  109. && prev.pageSize === next.pageSize
  110. && (prev.search ?? '') === (next.search ?? '')
  111. && (prev.filter ?? '') === (next.filter ?? '')
  112. && (prev.protocol ?? '') === (next.protocol ?? '')
  113. && (prev.inbound ?? '') === (next.inbound ?? '')
  114. && (prev.sort ?? '') === (next.sort ?? '')
  115. && (prev.order ?? '') === (next.order ?? '')
  116. && (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
  117. && (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
  118. && (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
  119. && (prev.usageTo ?? 0) === (next.usageTo ?? 0)
  120. && (prev.autoRenew ?? '') === (next.autoRenew ?? '')
  121. && (prev.hasTgId ?? '') === (next.hasTgId ?? '')
  122. && (prev.hasComment ?? '') === (next.hasComment ?? '')
  123. && (prev.group ?? '') === (next.group ?? '')
  124. ) return prev;
  125. return next;
  126. });
  127. }, []);
  128. const listQuery = useQuery({
  129. queryKey: keys.clients.list(query),
  130. queryFn: () => fetchClientPage(query),
  131. staleTime: Infinity,
  132. placeholderData: keepPreviousData,
  133. });
  134. const inboundOptionsQuery = useQuery({
  135. queryKey: keys.inbounds.options(),
  136. queryFn: fetchInboundOptions,
  137. staleTime: Infinity,
  138. });
  139. const defaultsQuery = useQuery({
  140. queryKey: keys.settings.defaults(),
  141. queryFn: fetchDefaults,
  142. staleTime: Infinity,
  143. });
  144. const onlinesQuery = useQuery({
  145. queryKey: keys.clients.onlines(),
  146. queryFn: async () => {
  147. const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
  148. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
  149. const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
  150. return Array.isArray(validated.obj) ? validated.obj : [];
  151. },
  152. staleTime: Infinity,
  153. });
  154. const clients = listQuery.data?.items ?? [];
  155. const total = listQuery.data?.total ?? 0;
  156. const filtered = listQuery.data?.filtered ?? 0;
  157. const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
  158. const allGroups = listQuery.data?.groups ?? [];
  159. const fetched = listQuery.data !== undefined;
  160. const loading = listQuery.isFetching;
  161. const inbounds = inboundOptionsQuery.data ?? [];
  162. const onlines = onlinesQuery.data ?? [];
  163. const defaults = defaultsQuery.data ?? {};
  164. const subSettings: SubSettings = useMemo(() => ({
  165. enable: !!defaults.subEnable,
  166. subURI: (defaults.subURI as string) || '',
  167. subJsonURI: (defaults.subJsonURI as string) || '',
  168. subJsonEnable: !!defaults.subJsonEnable,
  169. subClashURI: (defaults.subClashURI as string) || '',
  170. subClashEnable: !!defaults.subClashEnable,
  171. }), [
  172. defaults.subEnable,
  173. defaults.subURI,
  174. defaults.subJsonURI,
  175. defaults.subJsonEnable,
  176. defaults.subClashURI,
  177. defaults.subClashEnable,
  178. ]);
  179. const ipLimitEnable = !!defaults.ipLimitEnable;
  180. const tgBotEnable = !!defaults.tgBotEnable;
  181. const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
  182. const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
  183. const pageSize = (defaults.pageSize as number) ?? 0;
  184. // Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
  185. // mutate inbound rows server-side too — adding a client appends to
  186. // settings.clients on each attached inbound, the slim list's per-inbound
  187. // client count is derived from that. Invalidate both buckets so the
  188. // Inbounds page and any open edit modal pick up the new shape without
  189. // a manual reload.
  190. const invalidateAll = useCallback(
  191. () => Promise.all([
  192. queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
  193. queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
  194. ]),
  195. [queryClient],
  196. );
  197. const refresh = useCallback(async () => {
  198. await invalidateAll();
  199. }, [invalidateAll]);
  200. const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
  201. if (!email) return null;
  202. const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
  203. if (!msg?.success || !msg.obj) return null;
  204. const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
  205. return validated.obj;
  206. }, []);
  207. const createMut = useMutation({
  208. mutationFn: (payload: unknown) =>
  209. HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
  210. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  211. });
  212. const bulkAssignGroupMut = useMutation({
  213. mutationFn: (body: { emails: string[]; group: string }) =>
  214. HttpUtil.post('/panel/api/clients/bulkAssignGroup', body, JSON_HEADERS),
  215. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  216. });
  217. const updateMut = useMutation({
  218. mutationFn: ({ email, client }: { email: string; client: unknown }) =>
  219. HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
  220. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  221. });
  222. const removeMut = useMutation({
  223. mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
  224. const url = keepTraffic
  225. ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
  226. : `/panel/api/clients/del/${encodeURIComponent(email)}`;
  227. return HttpUtil.post(url);
  228. },
  229. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  230. });
  231. const bulkDeleteMut = useMutation({
  232. mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
  233. const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
  234. return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
  235. },
  236. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  237. });
  238. const bulkCreateMut = useMutation({
  239. mutationFn: async (payloads: unknown[]): Promise<Msg<BulkCreateResult>> => {
  240. const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
  241. return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
  242. },
  243. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  244. });
  245. const bulkAdjustMut = useMutation({
  246. mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
  247. const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
  248. return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
  249. },
  250. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  251. });
  252. const attachMut = useMutation({
  253. mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
  254. HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
  255. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  256. });
  257. const detachMut = useMutation({
  258. mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
  259. HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
  260. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  261. });
  262. const resetTrafficMut = useMutation({
  263. mutationFn: (email: string) =>
  264. HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
  265. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  266. });
  267. const resetAllTrafficsMut = useMutation({
  268. mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
  269. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  270. });
  271. const delDepletedMut = useMutation({
  272. mutationFn: async () => {
  273. const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
  274. return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
  275. },
  276. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  277. });
  278. const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
  279. const update = useCallback((email: string, client: unknown) => {
  280. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  281. return updateMut.mutateAsync({ email, client });
  282. }, [updateMut]);
  283. const remove = useCallback((email: string, keepTraffic = false) => {
  284. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  285. return removeMut.mutateAsync({ email, keepTraffic });
  286. }, [removeMut]);
  287. const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
  288. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
  289. return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
  290. }, [bulkDeleteMut]);
  291. const bulkCreate = useCallback((payloads: unknown[]) => {
  292. if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
  293. return bulkCreateMut.mutateAsync(payloads);
  294. }, [bulkCreateMut]);
  295. const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
  296. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
  297. return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
  298. }, [bulkAdjustMut]);
  299. const bulkAssignGroup = useCallback((emails: string[], group: string) => {
  300. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
  301. return bulkAssignGroupMut.mutateAsync({ emails, group });
  302. }, [bulkAssignGroupMut]);
  303. const attach = useCallback((email: string, inboundIds: number[]) => {
  304. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  305. return attachMut.mutateAsync({ email, inboundIds });
  306. }, [attachMut]);
  307. const detach = useCallback((email: string, inboundIds: number[]) => {
  308. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  309. return detachMut.mutateAsync({ email, inboundIds });
  310. }, [detachMut]);
  311. const resetTraffic = useCallback((client: ClientRecord) => {
  312. if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
  313. return resetTrafficMut.mutateAsync(client.email);
  314. }, [resetTrafficMut]);
  315. const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
  316. const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
  317. const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
  318. if (!client?.email) return null;
  319. const payload = {
  320. email: client.email,
  321. subId: client.subId,
  322. id: client.uuid,
  323. password: client.password,
  324. auth: client.auth,
  325. totalGB: client.totalGB || 0,
  326. expiryTime: client.expiryTime || 0,
  327. limitIp: client.limitIp || 0,
  328. comment: client.comment || '',
  329. enable: !!enable,
  330. };
  331. return update(client.email, payload);
  332. }, [update]);
  333. // WS-driven in-place merges. Page wires these via useWebSocket; the bridge
  334. // covers coarse 'invalidate' and 'inbounds' events centrally.
  335. const queryRef = useRef(query);
  336. queryRef.current = query;
  337. const applyTrafficEvent = useCallback((payload: unknown) => {
  338. if (!payload || typeof payload !== 'object') return;
  339. const p = payload as { onlineClients?: string[] };
  340. if (Array.isArray(p.onlineClients)) {
  341. queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
  342. }
  343. }, [queryClient]);
  344. const applyClientStatsEvent = useCallback((payload: unknown) => {
  345. if (!payload || typeof payload !== 'object') return;
  346. const p = payload as { clients?: (ClientTraffic & { email?: string })[] };
  347. if (!Array.isArray(p.clients) || p.clients.length === 0) return;
  348. const byEmail = new Map<string, ClientTraffic>();
  349. for (const row of p.clients) {
  350. if (row && row.email) byEmail.set(row.email, row);
  351. }
  352. queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
  353. if (!prev) return prev;
  354. let touched = false;
  355. const next = prev.items.slice();
  356. for (let i = 0; i < next.length; i++) {
  357. const row = next[i];
  358. const upd = byEmail.get(row?.email);
  359. if (!upd) continue;
  360. const merged: ClientTraffic = { ...(row.traffic || {}) };
  361. if (typeof upd.up === 'number') merged.up = upd.up;
  362. if (typeof upd.down === 'number') merged.down = upd.down;
  363. if (typeof upd.total === 'number') merged.total = upd.total;
  364. if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
  365. if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
  366. if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
  367. next[i] = { ...row, traffic: merged };
  368. touched = true;
  369. }
  370. if (!touched) return prev;
  371. return { ...prev, items: next };
  372. });
  373. }, [queryClient]);
  374. useEffect(() => {
  375. queryRef.current = query;
  376. }, [query]);
  377. return {
  378. clients,
  379. total,
  380. filtered,
  381. summary,
  382. allGroups,
  383. hydrate,
  384. query,
  385. setQuery,
  386. inbounds,
  387. onlines,
  388. loading,
  389. fetched,
  390. subSettings,
  391. ipLimitEnable,
  392. tgBotEnable,
  393. expireDiff,
  394. trafficDiff,
  395. pageSize,
  396. refresh,
  397. create,
  398. bulkCreate,
  399. update,
  400. remove,
  401. bulkDelete,
  402. bulkAdjust,
  403. bulkAssignGroup,
  404. attach,
  405. detach,
  406. resetTraffic,
  407. resetAllTraffics,
  408. delDepleted,
  409. setEnable,
  410. applyTrafficEvent,
  411. applyClientStatsEvent,
  412. };
  413. }