profiles.tsx 12 KB

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