profile-item.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. import dayjs from "dayjs";
  2. import { mutate } from "swr";
  3. import { useEffect, useState } from "react";
  4. import { useLockFn } from "ahooks";
  5. import { useTranslation } from "react-i18next";
  6. import { useSortable } from "@dnd-kit/sortable";
  7. import { CSS } from "@dnd-kit/utilities";
  8. import {
  9. Box,
  10. Typography,
  11. LinearProgress,
  12. IconButton,
  13. keyframes,
  14. MenuItem,
  15. Menu,
  16. CircularProgress,
  17. } from "@mui/material";
  18. import { RefreshRounded, DragIndicator } from "@mui/icons-material";
  19. import { useLoadingCache, useSetLoadingCache } from "@/services/states";
  20. import { updateProfile, viewProfile } from "@/services/cmds";
  21. import { Notice } from "@/components/base";
  22. import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
  23. import { EditorViewer } from "@/components/profile/editor-viewer";
  24. import { ProfileBox } from "./profile-box";
  25. import parseTraffic from "@/utils/parse-traffic";
  26. import { ConfirmViewer } from "@/components/profile/confirm-viewer";
  27. import { open } from "@tauri-apps/api/shell";
  28. const round = keyframes`
  29. from { transform: rotate(0deg); }
  30. to { transform: rotate(360deg); }
  31. `;
  32. interface Props {
  33. id: string;
  34. selected: boolean;
  35. activating: boolean;
  36. itemData: IProfileItem;
  37. onSelect: (force: boolean) => void;
  38. onEdit: () => void;
  39. onChange?: (prev?: string, curr?: string) => void;
  40. onDelete: () => void;
  41. }
  42. export const ProfileItem = (props: Props) => {
  43. const {
  44. selected,
  45. activating,
  46. itemData,
  47. onSelect,
  48. onEdit,
  49. onChange,
  50. onDelete,
  51. } = props;
  52. const { attributes, listeners, setNodeRef, transform, transition } =
  53. useSortable({ id: props.id });
  54. const { t } = useTranslation();
  55. const [anchorEl, setAnchorEl] = useState<any>(null);
  56. const [position, setPosition] = useState({ left: 0, top: 0 });
  57. const loadingCache = useLoadingCache();
  58. const setLoadingCache = useSetLoadingCache();
  59. const { uid, name = "Profile", extra, updated = 0, option } = itemData;
  60. // local file mode
  61. // remote file mode
  62. // remote file mode
  63. const hasUrl = !!itemData.url;
  64. const hasExtra = !!extra; // only subscription url has extra info
  65. const hasHome = !!itemData.home; // only subscription url has home page
  66. const { upload = 0, download = 0, total = 0 } = extra ?? {};
  67. const from = parseUrl(itemData.url);
  68. const description = itemData.desc;
  69. const expire = parseExpire(extra?.expire);
  70. const progress = Math.round(((download + upload) * 100) / (total + 0.1));
  71. const loading = loadingCache[itemData.uid] ?? false;
  72. // interval update fromNow field
  73. const [, setRefresh] = useState({});
  74. useEffect(() => {
  75. if (!hasUrl) return;
  76. let timer: any = null;
  77. const handler = () => {
  78. const now = Date.now();
  79. const lastUpdate = updated * 1000;
  80. // 大于一天的不管
  81. if (now - lastUpdate >= 24 * 36e5) return;
  82. const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
  83. timer = setTimeout(() => {
  84. setRefresh({});
  85. handler();
  86. }, wait);
  87. };
  88. handler();
  89. return () => {
  90. if (timer) clearTimeout(timer);
  91. };
  92. }, [hasUrl, updated]);
  93. const [fileOpen, setFileOpen] = useState(false);
  94. const [rulesOpen, setRulesOpen] = useState(false);
  95. const [proxiesOpen, setProxiesOpen] = useState(false);
  96. const [groupsOpen, setGroupsOpen] = useState(false);
  97. const [mergeOpen, setMergeOpen] = useState(false);
  98. const [scriptOpen, setScriptOpen] = useState(false);
  99. const [confirmOpen, setConfirmOpen] = useState(false);
  100. const onOpenHome = () => {
  101. setAnchorEl(null);
  102. open(itemData.home ?? "");
  103. };
  104. const onEditInfo = () => {
  105. setAnchorEl(null);
  106. onEdit();
  107. };
  108. const onEditFile = () => {
  109. setAnchorEl(null);
  110. setFileOpen(true);
  111. };
  112. const onEditRules = () => {
  113. setAnchorEl(null);
  114. setRulesOpen(true);
  115. };
  116. const onEditProxies = () => {
  117. setAnchorEl(null);
  118. setProxiesOpen(true);
  119. };
  120. const onEditGroups = () => {
  121. setAnchorEl(null);
  122. setGroupsOpen(true);
  123. };
  124. const onEditMerge = () => {
  125. setAnchorEl(null);
  126. setMergeOpen(true);
  127. };
  128. const onEditScript = () => {
  129. setAnchorEl(null);
  130. setScriptOpen(true);
  131. };
  132. const onForceSelect = () => {
  133. setAnchorEl(null);
  134. onSelect(true);
  135. };
  136. const onOpenFile = useLockFn(async () => {
  137. setAnchorEl(null);
  138. try {
  139. await viewProfile(itemData.uid);
  140. } catch (err: any) {
  141. Notice.error(err?.message || err.toString());
  142. }
  143. });
  144. /// 0 不使用任何代理
  145. /// 1 使用订阅好的代理
  146. /// 2 至少使用一个代理,根据订阅,如果没订阅,默认使用系统代理
  147. const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
  148. setAnchorEl(null);
  149. setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
  150. const option: Partial<IProfileOption> = {};
  151. if (type === 0) {
  152. option.with_proxy = false;
  153. option.self_proxy = false;
  154. } else if (type === 1) {
  155. // nothing
  156. } else if (type === 2) {
  157. if (itemData.option?.self_proxy) {
  158. option.with_proxy = false;
  159. option.self_proxy = true;
  160. } else {
  161. option.with_proxy = true;
  162. option.self_proxy = false;
  163. }
  164. }
  165. try {
  166. await updateProfile(itemData.uid, option);
  167. mutate("getProfiles");
  168. } catch (err: any) {
  169. const errmsg = err?.message || err.toString();
  170. Notice.error(
  171. errmsg.replace(/error sending request for url (\S+?): /, "")
  172. );
  173. } finally {
  174. setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
  175. }
  176. });
  177. const urlModeMenu = (
  178. hasHome ? [{ label: "Home", handler: onOpenHome, disabled: false }] : []
  179. ).concat([
  180. { label: "Select", handler: onForceSelect, disabled: false },
  181. { label: "Edit Info", handler: onEditInfo, disabled: false },
  182. { label: "Edit File", handler: onEditFile, disabled: false },
  183. {
  184. label: "Edit Rules",
  185. handler: onEditRules,
  186. disabled: option?.rules === null,
  187. },
  188. {
  189. label: "Edit Proxies",
  190. handler: onEditProxies,
  191. disabled: option?.proxies === null,
  192. },
  193. {
  194. label: "Edit Groups",
  195. handler: onEditGroups,
  196. disabled: option?.groups === null,
  197. },
  198. {
  199. label: "Edit Merge",
  200. handler: onEditMerge,
  201. disabled: option?.merge === null,
  202. },
  203. {
  204. label: "Edit Script",
  205. handler: onEditScript,
  206. disabled: option?.script === null,
  207. },
  208. { label: "Open File", handler: onOpenFile, disabled: false },
  209. { label: "Update", handler: () => onUpdate(0), disabled: false },
  210. { label: "Update(Proxy)", handler: () => onUpdate(2), disabled: false },
  211. {
  212. label: "Delete",
  213. handler: () => {
  214. setAnchorEl(null);
  215. setConfirmOpen(true);
  216. },
  217. disabled: false,
  218. },
  219. ]);
  220. const fileModeMenu = [
  221. { label: "Select", handler: onForceSelect, disabled: false },
  222. { label: "Edit Info", handler: onEditInfo, disabled: false },
  223. { label: "Edit File", handler: onEditFile, disabled: false },
  224. {
  225. label: "Edit Rules",
  226. handler: onEditRules,
  227. disabled: option?.rules === null,
  228. },
  229. {
  230. label: "Edit Proxies",
  231. handler: onEditProxies,
  232. disabled: option?.proxies === null,
  233. },
  234. {
  235. label: "Edit Groups",
  236. handler: onEditGroups,
  237. disabled: option?.groups === null,
  238. },
  239. {
  240. label: "Edit Merge",
  241. handler: onEditMerge,
  242. disabled: option?.merge === null,
  243. },
  244. {
  245. label: "Edit Script",
  246. handler: onEditScript,
  247. disabled: option?.script === null,
  248. },
  249. { label: "Open File", handler: onOpenFile, disabled: false },
  250. {
  251. label: "Delete",
  252. handler: () => {
  253. setAnchorEl(null);
  254. setConfirmOpen(true);
  255. },
  256. disabled: false,
  257. },
  258. ];
  259. const boxStyle = {
  260. height: 26,
  261. display: "flex",
  262. alignItems: "center",
  263. justifyContent: "space-between",
  264. };
  265. return (
  266. <Box
  267. sx={{
  268. transform: CSS.Transform.toString(transform),
  269. transition,
  270. }}
  271. >
  272. <ProfileBox
  273. aria-selected={selected}
  274. onClick={() => onSelect(false)}
  275. onContextMenu={(event) => {
  276. const { clientX, clientY } = event;
  277. setPosition({ top: clientY, left: clientX });
  278. setAnchorEl(event.currentTarget);
  279. event.preventDefault();
  280. }}
  281. >
  282. {activating && (
  283. <Box
  284. sx={{
  285. position: "absolute",
  286. display: "flex",
  287. justifyContent: "center",
  288. alignItems: "center",
  289. top: 10,
  290. left: 10,
  291. right: 10,
  292. bottom: 2,
  293. zIndex: 10,
  294. backdropFilter: "blur(2px)",
  295. }}
  296. >
  297. <CircularProgress color="inherit" size={20} />
  298. </Box>
  299. )}
  300. <Box position="relative">
  301. <Box sx={{ display: "flex", justifyContent: "start" }}>
  302. <Box
  303. ref={setNodeRef}
  304. sx={{ display: "flex", margin: "auto 0" }}
  305. {...attributes}
  306. {...listeners}
  307. >
  308. <DragIndicator
  309. sx={[
  310. { cursor: "move", marginLeft: "-6px" },
  311. ({ palette: { text } }) => {
  312. return { color: text.primary };
  313. },
  314. ]}
  315. />
  316. </Box>
  317. <Typography
  318. width="calc(100% - 36px)"
  319. sx={{ fontSize: "18px", fontWeight: "600", lineHeight: "26px" }}
  320. variant="h6"
  321. component="h2"
  322. noWrap
  323. title={name}
  324. >
  325. {name}
  326. </Typography>
  327. </Box>
  328. {/* only if has url can it be updated */}
  329. {hasUrl && (
  330. <IconButton
  331. title={t("Refresh")}
  332. sx={{
  333. position: "absolute",
  334. p: "3px",
  335. top: -1,
  336. right: -5,
  337. animation: loading ? `1s linear infinite ${round}` : "none",
  338. }}
  339. size="small"
  340. color="inherit"
  341. disabled={loading}
  342. onClick={(e) => {
  343. e.stopPropagation();
  344. onUpdate(1);
  345. }}
  346. >
  347. <RefreshRounded color="inherit" />
  348. </IconButton>
  349. )}
  350. </Box>
  351. {/* the second line show url's info or description */}
  352. <Box sx={boxStyle}>
  353. {
  354. <>
  355. {description ? (
  356. <Typography
  357. noWrap
  358. title={description}
  359. sx={{ fontSize: "14px" }}
  360. >
  361. {description}
  362. </Typography>
  363. ) : (
  364. hasUrl && (
  365. <Typography noWrap title={`${t("From")} ${from}`}>
  366. {from}
  367. </Typography>
  368. )
  369. )}
  370. {hasUrl && (
  371. <Typography
  372. noWrap
  373. flex="1 0 auto"
  374. fontSize={14}
  375. textAlign="right"
  376. title={`${t("Update Time")}: ${parseExpire(updated)}`}
  377. >
  378. {updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
  379. </Typography>
  380. )}
  381. </>
  382. }
  383. </Box>
  384. {/* the third line show extra info or last updated time */}
  385. {hasExtra ? (
  386. <Box sx={{ ...boxStyle, fontSize: 14 }}>
  387. <span title={t("Used / Total")}>
  388. {parseTraffic(upload + download)} / {parseTraffic(total)}
  389. </span>
  390. <span title={t("Expire Time")}>{expire}</span>
  391. </Box>
  392. ) : (
  393. <Box sx={{ ...boxStyle, fontSize: 12, justifyContent: "flex-end" }}>
  394. <span title={t("Update Time")}>{parseExpire(updated)}</span>
  395. </Box>
  396. )}
  397. <LinearProgress
  398. variant="determinate"
  399. value={progress}
  400. style={{ opacity: progress > 0 ? 1 : 0 }}
  401. />
  402. </ProfileBox>
  403. <Menu
  404. open={!!anchorEl}
  405. anchorEl={anchorEl}
  406. onClose={() => setAnchorEl(null)}
  407. anchorPosition={position}
  408. anchorReference="anchorPosition"
  409. transitionDuration={225}
  410. MenuListProps={{ sx: { py: 0.5 } }}
  411. onContextMenu={(e) => {
  412. setAnchorEl(null);
  413. e.preventDefault();
  414. }}
  415. >
  416. {(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
  417. <MenuItem
  418. key={item.label}
  419. onClick={item.handler}
  420. disabled={item.disabled}
  421. sx={[
  422. {
  423. minWidth: 120,
  424. },
  425. (theme) => {
  426. return {
  427. color:
  428. item.label === "Delete"
  429. ? theme.palette.error.main
  430. : undefined,
  431. };
  432. },
  433. ]}
  434. dense
  435. >
  436. {t(item.label)}
  437. </MenuItem>
  438. ))}
  439. </Menu>
  440. <EditorViewer
  441. mode="profile"
  442. property={uid}
  443. open={fileOpen}
  444. language="yaml"
  445. schema="clash"
  446. onChange={onChange}
  447. onClose={() => setFileOpen(false)}
  448. />
  449. <RulesEditorViewer
  450. property={option?.rules ?? ""}
  451. open={rulesOpen}
  452. language="yaml"
  453. schema={undefined}
  454. onChange={onChange}
  455. onClose={() => setRulesOpen(false)}
  456. />
  457. <EditorViewer
  458. mode="profile"
  459. property={option?.proxies ?? ""}
  460. open={proxiesOpen}
  461. language="yaml"
  462. schema={undefined}
  463. onChange={onChange}
  464. onClose={() => setProxiesOpen(false)}
  465. />
  466. <EditorViewer
  467. mode="profile"
  468. property={option?.groups ?? ""}
  469. open={groupsOpen}
  470. language="yaml"
  471. schema={undefined}
  472. onChange={onChange}
  473. onClose={() => setGroupsOpen(false)}
  474. />
  475. <EditorViewer
  476. mode="profile"
  477. property={option?.merge ?? ""}
  478. open={mergeOpen}
  479. language="yaml"
  480. schema="merge"
  481. onChange={onChange}
  482. onClose={() => setMergeOpen(false)}
  483. />
  484. <EditorViewer
  485. mode="profile"
  486. property={option?.script ?? ""}
  487. open={scriptOpen}
  488. language="javascript"
  489. schema={undefined}
  490. onChange={onChange}
  491. onClose={() => setScriptOpen(false)}
  492. />
  493. <ConfirmViewer
  494. title={t("Confirm deletion")}
  495. message={t("This operation is not reversible")}
  496. open={confirmOpen}
  497. onClose={() => setConfirmOpen(false)}
  498. onConfirm={() => {
  499. onDelete();
  500. setConfirmOpen(false);
  501. }}
  502. />
  503. </Box>
  504. );
  505. };
  506. function parseUrl(url?: string) {
  507. if (!url) return "";
  508. const regex = /https?:\/\/(.+?)\//;
  509. const result = url.match(regex);
  510. return result ? result[1] : "local file";
  511. }
  512. function parseExpire(expire?: number) {
  513. if (!expire) return "-";
  514. return dayjs(expire * 1000).format("YYYY-MM-DD");
  515. }