profile-item.tsx 15 KB

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