profiles.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import useSWR, { mutate } from "swr";
  2. import { useMemo, useRef, useState } from "react";
  3. import { useLockFn } from "ahooks";
  4. import { useSetRecoilState } from "recoil";
  5. import {
  6. Box,
  7. Button,
  8. Grid,
  9. IconButton,
  10. Stack,
  11. TextField,
  12. Divider,
  13. } from "@mui/material";
  14. import {
  15. DndContext,
  16. closestCenter,
  17. KeyboardSensor,
  18. PointerSensor,
  19. useSensor,
  20. useSensors,
  21. DragEndEvent,
  22. } from "@dnd-kit/core";
  23. import {
  24. SortableContext,
  25. sortableKeyboardCoordinates,
  26. } from "@dnd-kit/sortable";
  27. import { LoadingButton } from "@mui/lab";
  28. import {
  29. ClearRounded,
  30. ContentPasteRounded,
  31. LocalFireDepartmentRounded,
  32. RefreshRounded,
  33. TextSnippetOutlined,
  34. } from "@mui/icons-material";
  35. import { useTranslation } from "react-i18next";
  36. import {
  37. getProfiles,
  38. importProfile,
  39. enhanceProfiles,
  40. getRuntimeLogs,
  41. deleteProfile,
  42. updateProfile,
  43. reorderProfile,
  44. } from "@/services/cmds";
  45. import { atomLoadingCache } from "@/services/states";
  46. import { closeAllConnections } from "@/services/api";
  47. import { BasePage, DialogRef, Notice } from "@/components/base";
  48. import {
  49. ProfileViewer,
  50. ProfileViewerRef,
  51. } from "@/components/profile/profile-viewer";
  52. import { ProfileItem } from "@/components/profile/profile-item";
  53. import { ProfileMore } from "@/components/profile/profile-more";
  54. import { useProfiles } from "@/hooks/use-profiles";
  55. import { ConfigViewer } from "@/components/setting/mods/config-viewer";
  56. import { throttle } from "lodash-es";
  57. import { useRecoilState } from "recoil";
  58. import { atomThemeMode } from "@/services/states";
  59. const ProfilePage = () => {
  60. const { t } = useTranslation();
  61. const [url, setUrl] = useState("");
  62. const [disabled, setDisabled] = useState(false);
  63. const [activating, setActivating] = useState("");
  64. const [loading, setLoading] = useState(false);
  65. const sensors = useSensors(
  66. useSensor(PointerSensor),
  67. useSensor(KeyboardSensor, {
  68. coordinateGetter: sortableKeyboardCoordinates,
  69. })
  70. );
  71. const {
  72. profiles = {},
  73. activateSelected,
  74. patchProfiles,
  75. mutateProfiles,
  76. } = useProfiles();
  77. const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
  78. "getRuntimeLogs",
  79. getRuntimeLogs
  80. );
  81. const chain = profiles.chain || [];
  82. const viewerRef = useRef<ProfileViewerRef>(null);
  83. const configRef = useRef<DialogRef>(null);
  84. // distinguish type
  85. const { regularItems, enhanceItems } = useMemo(() => {
  86. const items = profiles.items || [];
  87. const chain = profiles.chain || [];
  88. const type1 = ["local", "remote"];
  89. const type2 = ["merge", "script"];
  90. const regularItems = items.filter((i) => i && type1.includes(i.type!));
  91. const restItems = items.filter((i) => i && type2.includes(i.type!));
  92. const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i]));
  93. const enhanceItems = chain
  94. .map((i) => restMap[i]!)
  95. .filter(Boolean)
  96. .concat(restItems.filter((i) => !chain.includes(i.uid)));
  97. return { regularItems, enhanceItems };
  98. }, [profiles]);
  99. const onImport = async () => {
  100. if (!url) return;
  101. setLoading(true);
  102. try {
  103. await importProfile(url);
  104. Notice.success("Successfully import profile.");
  105. setUrl("");
  106. setLoading(false);
  107. getProfiles().then((newProfiles) => {
  108. mutate("getProfiles", newProfiles);
  109. const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
  110. if (!newProfiles.current && remoteItem) {
  111. const current = remoteItem.uid;
  112. patchProfiles({ current });
  113. mutateLogs();
  114. setTimeout(() => activateSelected(), 2000);
  115. }
  116. });
  117. } catch (err: any) {
  118. Notice.error(err.message || err.toString());
  119. setLoading(false);
  120. } finally {
  121. setDisabled(false);
  122. setLoading(false);
  123. }
  124. };
  125. const onDragEnd = async (event: DragEndEvent) => {
  126. const { active, over } = event;
  127. if (over) {
  128. if (active.id !== over.id) {
  129. await reorderProfile(active.id.toString(), over.id.toString());
  130. mutateProfiles();
  131. }
  132. }
  133. };
  134. const onSelect = useLockFn(async (current: string, force: boolean) => {
  135. if (!force && current === profiles.current) return;
  136. // 避免大多数情况下loading态闪烁
  137. const reset = setTimeout(() => setActivating(current), 100);
  138. try {
  139. await patchProfiles({ current });
  140. mutateLogs();
  141. closeAllConnections();
  142. setTimeout(() => activateSelected(), 2000);
  143. Notice.success("Refresh clash config", 1000);
  144. } catch (err: any) {
  145. Notice.error(err?.message || err.toString(), 4000);
  146. } finally {
  147. clearTimeout(reset);
  148. setActivating("");
  149. }
  150. });
  151. const onEnhance = useLockFn(async () => {
  152. try {
  153. await enhanceProfiles();
  154. mutateLogs();
  155. Notice.success("Refresh clash config", 1000);
  156. } catch (err: any) {
  157. Notice.error(err.message || err.toString(), 3000);
  158. }
  159. });
  160. const onEnable = useLockFn(async (uid: string) => {
  161. if (chain.includes(uid)) return;
  162. const newChain = [...chain, uid];
  163. await patchProfiles({ chain: newChain });
  164. mutateLogs();
  165. });
  166. const onDisable = useLockFn(async (uid: string) => {
  167. if (!chain.includes(uid)) return;
  168. const newChain = chain.filter((i) => i !== uid);
  169. await patchProfiles({ chain: newChain });
  170. mutateLogs();
  171. });
  172. const onDelete = useLockFn(async (uid: string) => {
  173. try {
  174. await onDisable(uid);
  175. await deleteProfile(uid);
  176. mutateProfiles();
  177. mutateLogs();
  178. } catch (err: any) {
  179. Notice.error(err?.message || err.toString());
  180. }
  181. });
  182. const onMoveTop = useLockFn(async (uid: string) => {
  183. if (!chain.includes(uid)) return;
  184. const newChain = [uid].concat(chain.filter((i) => i !== uid));
  185. await patchProfiles({ chain: newChain });
  186. mutateLogs();
  187. });
  188. const onMoveEnd = useLockFn(async (uid: string) => {
  189. if (!chain.includes(uid)) return;
  190. const newChain = chain.filter((i) => i !== uid).concat([uid]);
  191. await patchProfiles({ chain: newChain });
  192. mutateLogs();
  193. });
  194. // 更新所有订阅
  195. const setLoadingCache = useSetRecoilState(atomLoadingCache);
  196. const onUpdateAll = useLockFn(async () => {
  197. const throttleMutate = throttle(mutateProfiles, 2000, {
  198. trailing: true,
  199. });
  200. const updateOne = async (uid: string) => {
  201. try {
  202. await updateProfile(uid);
  203. throttleMutate();
  204. } finally {
  205. setLoadingCache((cache) => ({ ...cache, [uid]: false }));
  206. }
  207. };
  208. return new Promise((resolve) => {
  209. setLoadingCache((cache) => {
  210. // 获取没有正在更新的订阅
  211. const items = regularItems.filter(
  212. (e) => e.type === "remote" && !cache[e.uid]
  213. );
  214. const change = Object.fromEntries(items.map((e) => [e.uid, true]));
  215. Promise.allSettled(items.map((e) => updateOne(e.uid))).then(resolve);
  216. return { ...cache, ...change };
  217. });
  218. });
  219. });
  220. const onCopyLink = async () => {
  221. const text = await navigator.clipboard.readText();
  222. if (text) setUrl(text);
  223. };
  224. const [mode] = useRecoilState(atomThemeMode);
  225. const islight = mode === "light" ? true : false;
  226. const dividercolor = islight
  227. ? "rgba(0, 0, 0, 0.06)"
  228. : "rgba(255, 255, 255, 0.06)";
  229. return (
  230. <BasePage
  231. full
  232. title={t("Profiles")}
  233. contentStyle={{ height: "100%" }}
  234. header={
  235. <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
  236. <IconButton
  237. size="small"
  238. color="inherit"
  239. title={t("Update All Profiles")}
  240. onClick={onUpdateAll}
  241. >
  242. <RefreshRounded />
  243. </IconButton>
  244. <IconButton
  245. size="small"
  246. color="inherit"
  247. title={t("View Runtime Config")}
  248. onClick={() => configRef.current?.open()}
  249. >
  250. <TextSnippetOutlined />
  251. </IconButton>
  252. <IconButton
  253. size="small"
  254. color="primary"
  255. title={t("Reactivate Profiles")}
  256. onClick={onEnhance}
  257. >
  258. <LocalFireDepartmentRounded />
  259. </IconButton>
  260. </Box>
  261. }
  262. >
  263. <Stack
  264. direction="row"
  265. spacing={1}
  266. sx={{
  267. pt: 1,
  268. mb: 0.5,
  269. mx: "10px",
  270. height: "36px",
  271. display: "flex",
  272. alignItems: "center",
  273. }}
  274. >
  275. <TextField
  276. hiddenLabel
  277. fullWidth
  278. size="small"
  279. value={url}
  280. variant="outlined"
  281. autoComplete="off"
  282. spellCheck="false"
  283. onChange={(e) => setUrl(e.target.value)}
  284. sx={{ input: { py: 0.65, px: 1.25 } }}
  285. placeholder={t("Profile URL")}
  286. InputProps={{
  287. sx: { pr: 1 },
  288. endAdornment: !url ? (
  289. <IconButton
  290. size="small"
  291. sx={{ p: 0.5 }}
  292. title={t("Paste")}
  293. onClick={onCopyLink}
  294. >
  295. <ContentPasteRounded fontSize="inherit" />
  296. </IconButton>
  297. ) : (
  298. <IconButton
  299. size="small"
  300. sx={{ p: 0.5 }}
  301. title={t("Clear")}
  302. onClick={() => setUrl("")}
  303. >
  304. <ClearRounded fontSize="inherit" />
  305. </IconButton>
  306. ),
  307. }}
  308. />
  309. <LoadingButton
  310. disabled={!url || disabled}
  311. loading={loading}
  312. variant="contained"
  313. size="small"
  314. sx={{ borderRadius: "6px" }}
  315. onClick={onImport}
  316. >
  317. {t("Import")}
  318. </LoadingButton>
  319. <Button
  320. variant="contained"
  321. size="small"
  322. sx={{ borderRadius: "6px" }}
  323. onClick={() => viewerRef.current?.create()}
  324. >
  325. {t("New")}
  326. </Button>
  327. </Stack>
  328. <Box
  329. sx={{
  330. pt: 1,
  331. mb: 0.5,
  332. pl: "10px",
  333. mr: "10px",
  334. height: "calc(100% - 20px)",
  335. overflowY: "auto",
  336. }}
  337. >
  338. <DndContext
  339. sensors={sensors}
  340. collisionDetection={closestCenter}
  341. onDragEnd={onDragEnd}
  342. >
  343. <Box sx={{ mb: 1.5 }}>
  344. <Grid container spacing={{ xs: 1, lg: 1 }}>
  345. <SortableContext
  346. items={regularItems.map((x) => {
  347. return x.uid;
  348. })}
  349. >
  350. {regularItems.map((item) => (
  351. <Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
  352. <ProfileItem
  353. id={item.uid}
  354. selected={profiles.current === item.uid}
  355. activating={activating === item.uid}
  356. itemData={item}
  357. onSelect={(f) => onSelect(item.uid, f)}
  358. onEdit={() => viewerRef.current?.edit(item)}
  359. />
  360. </Grid>
  361. ))}
  362. </SortableContext>
  363. </Grid>
  364. </Box>
  365. </DndContext>
  366. {enhanceItems.length > 0 && (
  367. <Divider
  368. variant="middle"
  369. flexItem
  370. sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
  371. ></Divider>
  372. )}
  373. {enhanceItems.length > 0 && (
  374. <Box sx={{ mt: 1.5 }}>
  375. <Grid container spacing={{ xs: 1, lg: 1 }}>
  376. {enhanceItems.map((item) => (
  377. <Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
  378. <ProfileMore
  379. selected={!!chain.includes(item.uid)}
  380. itemData={item}
  381. enableNum={chain.length || 0}
  382. logInfo={chainLogs[item.uid]}
  383. onEnable={() => onEnable(item.uid)}
  384. onDisable={() => onDisable(item.uid)}
  385. onDelete={() => onDelete(item.uid)}
  386. onMoveTop={() => onMoveTop(item.uid)}
  387. onMoveEnd={() => onMoveEnd(item.uid)}
  388. onEdit={() => viewerRef.current?.edit(item)}
  389. />
  390. </Grid>
  391. ))}
  392. </Grid>
  393. </Box>
  394. )}
  395. </Box>
  396. <ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
  397. <ConfigViewer ref={configRef} />
  398. </BasePage>
  399. );
  400. };
  401. export default ProfilePage;