profiles.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  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 { ProfileMore } from "@/components/profile/profile-more";
  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 [activating, setActivating] = useState("");
  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 chain = profiles.chain || [];
  103. const viewerRef = useRef<ProfileViewerRef>(null);
  104. const configRef = useRef<DialogRef>(null);
  105. // distinguish type
  106. const { regularItems, enhanceItems } = useMemo(() => {
  107. const items = profiles.items || [];
  108. const chain = profiles.chain || [];
  109. const type1 = ["local", "remote"];
  110. const type2 = ["merge", "script"];
  111. const regularItems = items.filter((i) => i && type1.includes(i.type!));
  112. const restItems = items.filter((i) => i && type2.includes(i.type!));
  113. const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i]));
  114. const enhanceItems = chain
  115. .map((i) => restMap[i]!)
  116. .filter(Boolean)
  117. .concat(restItems.filter((i) => !chain.includes(i.uid)));
  118. return { regularItems, enhanceItems };
  119. }, [profiles]);
  120. const onImport = async () => {
  121. if (!url) return;
  122. setLoading(true);
  123. try {
  124. await importProfile(url);
  125. Notice.success(t("Profile Imported Successfully"));
  126. setUrl("");
  127. setLoading(false);
  128. getProfiles().then((newProfiles) => {
  129. mutate("getProfiles", newProfiles);
  130. const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
  131. if (!newProfiles.current && remoteItem) {
  132. const current = remoteItem.uid;
  133. patchProfiles({ current });
  134. mutateLogs();
  135. setTimeout(() => activateSelected(), 2000);
  136. }
  137. });
  138. } catch (err: any) {
  139. Notice.error(err.message || err.toString());
  140. setLoading(false);
  141. } finally {
  142. setDisabled(false);
  143. setLoading(false);
  144. }
  145. };
  146. const onDragEnd = async (event: DragEndEvent) => {
  147. const { active, over } = event;
  148. if (over) {
  149. if (active.id !== over.id) {
  150. await reorderProfile(active.id.toString(), over.id.toString());
  151. mutateProfiles();
  152. }
  153. }
  154. };
  155. const onSelect = useLockFn(async (current: string, force: boolean) => {
  156. if (!force && current === profiles.current) return;
  157. // 避免大多数情况下loading态闪烁
  158. const reset = setTimeout(() => setActivating(current), 100);
  159. try {
  160. await patchProfiles({ current });
  161. mutateLogs();
  162. closeAllConnections();
  163. setTimeout(() => activateSelected(), 2000);
  164. Notice.success(t("Profile Switched"), 1000);
  165. } catch (err: any) {
  166. Notice.error(err?.message || err.toString(), 4000);
  167. } finally {
  168. clearTimeout(reset);
  169. setActivating("");
  170. }
  171. });
  172. const onEnhance = useLockFn(async () => {
  173. try {
  174. await enhanceProfiles();
  175. mutateLogs();
  176. Notice.success(t("Profile Reactivated"), 1000);
  177. } catch (err: any) {
  178. Notice.error(err.message || err.toString(), 3000);
  179. }
  180. });
  181. const onEnable = useLockFn(async (uid: string) => {
  182. if (chain.includes(uid)) return;
  183. const newChain = [...chain, uid];
  184. await patchProfiles({ chain: newChain });
  185. mutateLogs();
  186. });
  187. const onDisable = useLockFn(async (uid: string) => {
  188. if (!chain.includes(uid)) return;
  189. const newChain = chain.filter((i) => i !== uid);
  190. await patchProfiles({ chain: newChain });
  191. mutateLogs();
  192. });
  193. const onDelete = useLockFn(async (uid: string) => {
  194. try {
  195. await onDisable(uid);
  196. await deleteProfile(uid);
  197. mutateProfiles();
  198. mutateLogs();
  199. } catch (err: any) {
  200. Notice.error(err?.message || err.toString());
  201. }
  202. });
  203. const onMoveTop = useLockFn(async (uid: string) => {
  204. if (!chain.includes(uid)) return;
  205. const newChain = [uid].concat(chain.filter((i) => i !== uid));
  206. await patchProfiles({ chain: newChain });
  207. mutateLogs();
  208. });
  209. const onMoveEnd = useLockFn(async (uid: string) => {
  210. if (!chain.includes(uid)) return;
  211. const newChain = chain.filter((i) => i !== uid).concat([uid]);
  212. await patchProfiles({ chain: newChain });
  213. mutateLogs();
  214. });
  215. // 更新所有订阅
  216. const setLoadingCache = useSetLoadingCache();
  217. const onUpdateAll = useLockFn(async () => {
  218. const throttleMutate = throttle(mutateProfiles, 2000, {
  219. trailing: true,
  220. });
  221. const updateOne = async (uid: string) => {
  222. try {
  223. await updateProfile(uid);
  224. throttleMutate();
  225. } finally {
  226. setLoadingCache((cache) => ({ ...cache, [uid]: false }));
  227. }
  228. };
  229. return new Promise((resolve) => {
  230. setLoadingCache((cache) => {
  231. // 获取没有正在更新的订阅
  232. const items = regularItems.filter(
  233. (e) => e.type === "remote" && !cache[e.uid]
  234. );
  235. const change = Object.fromEntries(items.map((e) => [e.uid, true]));
  236. Promise.allSettled(items.map((e) => updateOne(e.uid))).then(resolve);
  237. return { ...cache, ...change };
  238. });
  239. });
  240. });
  241. const onCopyLink = async () => {
  242. const text = await readText();
  243. if (text) setUrl(text);
  244. };
  245. const mode = useThemeMode();
  246. const islight = mode === "light" ? true : false;
  247. const dividercolor = islight
  248. ? "rgba(0, 0, 0, 0.06)"
  249. : "rgba(255, 255, 255, 0.06)";
  250. return (
  251. <BasePage
  252. full
  253. title={t("Profiles")}
  254. contentStyle={{ height: "100%" }}
  255. header={
  256. <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
  257. <IconButton
  258. size="small"
  259. color="inherit"
  260. title={t("Update All Profiles")}
  261. onClick={onUpdateAll}
  262. >
  263. <RefreshRounded />
  264. </IconButton>
  265. <IconButton
  266. size="small"
  267. color="inherit"
  268. title={t("View Runtime Config")}
  269. onClick={() => configRef.current?.open()}
  270. >
  271. <TextSnippetOutlined />
  272. </IconButton>
  273. <IconButton
  274. size="small"
  275. color="primary"
  276. title={t("Reactivate Profiles")}
  277. onClick={onEnhance}
  278. >
  279. <LocalFireDepartmentRounded />
  280. </IconButton>
  281. </Box>
  282. }
  283. >
  284. <Stack
  285. direction="row"
  286. spacing={1}
  287. sx={{
  288. pt: 1,
  289. mb: 0.5,
  290. mx: "10px",
  291. height: "36px",
  292. display: "flex",
  293. alignItems: "center",
  294. }}
  295. >
  296. <BaseStyledTextField
  297. value={url}
  298. variant="outlined"
  299. onChange={(e) => setUrl(e.target.value)}
  300. placeholder={t("Profile URL")}
  301. InputProps={{
  302. sx: { pr: 1 },
  303. endAdornment: !url ? (
  304. <IconButton
  305. size="small"
  306. sx={{ p: 0.5 }}
  307. title={t("Paste")}
  308. onClick={onCopyLink}
  309. >
  310. <ContentPasteRounded fontSize="inherit" />
  311. </IconButton>
  312. ) : (
  313. <IconButton
  314. size="small"
  315. sx={{ p: 0.5 }}
  316. title={t("Clear")}
  317. onClick={() => setUrl("")}
  318. >
  319. <ClearRounded fontSize="inherit" />
  320. </IconButton>
  321. ),
  322. }}
  323. />
  324. <LoadingButton
  325. disabled={!url || disabled}
  326. loading={loading}
  327. variant="contained"
  328. size="small"
  329. sx={{ borderRadius: "6px" }}
  330. onClick={onImport}
  331. >
  332. {t("Import")}
  333. </LoadingButton>
  334. <Button
  335. variant="contained"
  336. size="small"
  337. sx={{ borderRadius: "6px" }}
  338. onClick={() => viewerRef.current?.create()}
  339. >
  340. {t("New")}
  341. </Button>
  342. </Stack>
  343. <Box
  344. sx={{
  345. pt: 1,
  346. mb: 0.5,
  347. pl: "10px",
  348. mr: "10px",
  349. height: "calc(100% - 68px)",
  350. overflowY: "auto",
  351. }}
  352. >
  353. <DndContext
  354. sensors={sensors}
  355. collisionDetection={closestCenter}
  356. onDragEnd={onDragEnd}
  357. >
  358. <Box sx={{ mb: 1.5 }}>
  359. <Grid container spacing={{ xs: 1, lg: 1 }}>
  360. <SortableContext
  361. items={regularItems.map((x) => {
  362. return x.uid;
  363. })}
  364. >
  365. {regularItems.map((item) => (
  366. <Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
  367. <ProfileItem
  368. id={item.uid}
  369. selected={profiles.current === item.uid}
  370. activating={activating === item.uid}
  371. itemData={item}
  372. onSelect={(f) => onSelect(item.uid, f)}
  373. onEdit={() => viewerRef.current?.edit(item)}
  374. />
  375. </Grid>
  376. ))}
  377. </SortableContext>
  378. </Grid>
  379. </Box>
  380. </DndContext>
  381. {enhanceItems.length > 0 && (
  382. <Divider
  383. variant="middle"
  384. flexItem
  385. sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
  386. ></Divider>
  387. )}
  388. {enhanceItems.length > 0 && (
  389. <Box sx={{ mt: 1.5 }}>
  390. <Grid container spacing={{ xs: 1, lg: 1 }}>
  391. {enhanceItems.map((item) => (
  392. <Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
  393. <ProfileMore
  394. selected={!!chain.includes(item.uid)}
  395. itemData={item}
  396. enableNum={chain.length || 0}
  397. logInfo={chainLogs[item.uid]}
  398. onEnable={() => onEnable(item.uid)}
  399. onDisable={() => onDisable(item.uid)}
  400. onDelete={() => onDelete(item.uid)}
  401. onMoveTop={() => onMoveTop(item.uid)}
  402. onMoveEnd={() => onMoveEnd(item.uid)}
  403. onEdit={() => viewerRef.current?.edit(item)}
  404. />
  405. </Grid>
  406. ))}
  407. </Grid>
  408. </Box>
  409. )}
  410. </Box>
  411. <ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
  412. <ConfigViewer ref={configRef} />
  413. </BasePage>
  414. );
  415. };
  416. export default ProfilePage;