profiles.tsx 11 KB

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