profile-item.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import dayjs from "dayjs";
  2. import { mutate } from "swr";
  3. import { useEffect, useState } from "react";
  4. import { useLockFn } from "ahooks";
  5. import { useRecoilState } from "recoil";
  6. import { useTranslation } from "react-i18next";
  7. import {
  8. Box,
  9. Typography,
  10. LinearProgress,
  11. IconButton,
  12. keyframes,
  13. MenuItem,
  14. Menu,
  15. } from "@mui/material";
  16. import { RefreshRounded } from "@mui/icons-material";
  17. import { atomLoadingCache } from "@/services/states";
  18. import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
  19. import { Notice } from "@/components/base";
  20. import { InfoViewer } from "./info-viewer";
  21. import { EditorViewer } from "./editor-viewer";
  22. import { ProfileBox } from "./profile-box";
  23. import parseTraffic from "@/utils/parse-traffic";
  24. const round = keyframes`
  25. from { transform: rotate(0deg); }
  26. to { transform: rotate(360deg); }
  27. `;
  28. interface Props {
  29. selected: boolean;
  30. itemData: IProfileItem;
  31. onSelect: (force: boolean) => void;
  32. }
  33. export const ProfileItem = (props: Props) => {
  34. const { selected, itemData, onSelect } = props;
  35. const { t } = useTranslation();
  36. const [anchorEl, setAnchorEl] = useState<any>(null);
  37. const [position, setPosition] = useState({ left: 0, top: 0 });
  38. const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
  39. const { uid, name = "Profile", extra, updated = 0 } = itemData;
  40. // local file mode
  41. // remote file mode
  42. const hasUrl = !!itemData.url;
  43. const hasExtra = !!extra; // only subscription url has extra info
  44. const { upload = 0, download = 0, total = 0 } = extra ?? {};
  45. const from = parseUrl(itemData.url);
  46. const expire = parseExpire(extra?.expire);
  47. const progress = Math.round(((download + upload) * 100) / (total + 0.1));
  48. const loading = loadingCache[itemData.uid] ?? false;
  49. // interval update from now field
  50. const [, setRefresh] = useState({});
  51. useEffect(() => {
  52. if (!hasUrl) return;
  53. let timer: any = null;
  54. const handler = () => {
  55. const now = Date.now();
  56. const lastUpdate = updated * 1000;
  57. // 大于一天的不管
  58. if (now - lastUpdate >= 24 * 36e5) return;
  59. const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
  60. timer = setTimeout(() => {
  61. setRefresh({});
  62. handler();
  63. }, wait);
  64. };
  65. handler();
  66. return () => {
  67. if (timer) clearTimeout(timer);
  68. };
  69. }, [hasUrl, updated]);
  70. const [editOpen, setEditOpen] = useState(false);
  71. const [fileOpen, setFileOpen] = useState(false);
  72. const onEditInfo = () => {
  73. setAnchorEl(null);
  74. setEditOpen(true);
  75. };
  76. const onEditFile = () => {
  77. setAnchorEl(null);
  78. setFileOpen(true);
  79. };
  80. const onForceSelect = () => {
  81. setAnchorEl(null);
  82. onSelect(true);
  83. };
  84. const onOpenFile = useLockFn(async () => {
  85. setAnchorEl(null);
  86. try {
  87. await viewProfile(itemData.uid);
  88. } catch (err: any) {
  89. Notice.error(err?.message || err.toString());
  90. }
  91. });
  92. /// 0 不使用任何代理
  93. /// 1 使用配置好的代理
  94. /// 2 至少使用一个代理,根据配置,如果没配置,默认使用系统代理
  95. const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
  96. setAnchorEl(null);
  97. setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
  98. const option: Partial<IProfileOption> = {};
  99. if (type === 0) {
  100. option.with_proxy = false;
  101. option.self_proxy = false;
  102. } else if (type === 1) {
  103. // nothing
  104. } else if (type === 2) {
  105. if (itemData.option?.self_proxy) {
  106. option.with_proxy = false;
  107. option.self_proxy = true;
  108. } else {
  109. option.with_proxy = true;
  110. option.self_proxy = false;
  111. }
  112. }
  113. try {
  114. await updateProfile(itemData.uid, option);
  115. mutate("getProfiles");
  116. } catch (err: any) {
  117. const errmsg = err?.message || err.toString();
  118. Notice.error(
  119. errmsg.replace(/error sending request for url (\S+?): /, "")
  120. );
  121. } finally {
  122. setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
  123. }
  124. });
  125. const onDelete = useLockFn(async () => {
  126. setAnchorEl(null);
  127. try {
  128. await deleteProfile(itemData.uid);
  129. mutate("getProfiles");
  130. } catch (err: any) {
  131. Notice.error(err?.message || err.toString());
  132. }
  133. });
  134. const urlModeMenu = [
  135. { label: "Select", handler: onForceSelect },
  136. { label: "Edit Info", handler: onEditInfo },
  137. { label: "Edit File", handler: onEditFile },
  138. { label: "Open File", handler: onOpenFile },
  139. { label: "Update", handler: () => onUpdate(0) },
  140. { label: "Update(Proxy)", handler: () => onUpdate(2) },
  141. { label: "Delete", handler: onDelete },
  142. ];
  143. const fileModeMenu = [
  144. { label: "Select", handler: onForceSelect },
  145. { label: "Edit Info", handler: onEditInfo },
  146. { label: "Edit File", handler: onEditFile },
  147. { label: "Open File", handler: onOpenFile },
  148. { label: "Delete", handler: onDelete },
  149. ];
  150. const boxStyle = {
  151. height: 26,
  152. display: "flex",
  153. alignItems: "center",
  154. justifyContent: "space-between",
  155. };
  156. return (
  157. <>
  158. <ProfileBox
  159. aria-selected={selected}
  160. onClick={() => onSelect(false)}
  161. onContextMenu={(event) => {
  162. const { clientX, clientY } = event;
  163. setPosition({ top: clientY, left: clientX });
  164. setAnchorEl(event.currentTarget);
  165. event.preventDefault();
  166. }}
  167. >
  168. <Box position="relative">
  169. <Typography
  170. width="calc(100% - 36px)"
  171. variant="h6"
  172. component="h2"
  173. noWrap
  174. title={name}
  175. >
  176. {name}
  177. </Typography>
  178. {/* only if has url can it be updated */}
  179. {hasUrl && (
  180. <IconButton
  181. sx={{
  182. position: "absolute",
  183. p: "3px",
  184. top: -1,
  185. right: -5,
  186. animation: loading ? `1s linear infinite ${round}` : "none",
  187. }}
  188. size="small"
  189. color="inherit"
  190. disabled={loading}
  191. onClick={(e) => {
  192. e.stopPropagation();
  193. onUpdate(1);
  194. }}
  195. >
  196. <RefreshRounded color="inherit" />
  197. </IconButton>
  198. )}
  199. </Box>
  200. {/* the second line show url's info or description */}
  201. <Box sx={boxStyle}>
  202. {hasUrl ? (
  203. <>
  204. <Typography noWrap title={`From: ${from}`}>
  205. {from}
  206. </Typography>
  207. <Typography
  208. noWrap
  209. flex="1 0 auto"
  210. fontSize={14}
  211. textAlign="right"
  212. title={`Updated Time: ${parseExpire(updated)}`}
  213. >
  214. {updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
  215. </Typography>
  216. </>
  217. ) : (
  218. <Typography noWrap title={itemData.desc}>
  219. {itemData.desc}
  220. </Typography>
  221. )}
  222. </Box>
  223. {/* the third line show extra info or last updated time */}
  224. {hasExtra ? (
  225. <Box sx={{ ...boxStyle, fontSize: 14 }}>
  226. <span title="Used / Total">
  227. {parseTraffic(upload + download)} / {parseTraffic(total)}
  228. </span>
  229. <span title="Expire Time">{expire}</span>
  230. </Box>
  231. ) : (
  232. <Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
  233. <span title="Updated Time">{parseExpire(updated)}</span>
  234. </Box>
  235. )}
  236. <LinearProgress
  237. variant="determinate"
  238. value={progress}
  239. color="inherit"
  240. />
  241. </ProfileBox>
  242. <Menu
  243. open={!!anchorEl}
  244. anchorEl={anchorEl}
  245. onClose={() => setAnchorEl(null)}
  246. anchorPosition={position}
  247. anchorReference="anchorPosition"
  248. transitionDuration={225}
  249. MenuListProps={{ sx: { py: 0.5 } }}
  250. onContextMenu={(e) => {
  251. setAnchorEl(null);
  252. e.preventDefault();
  253. }}
  254. >
  255. {(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
  256. <MenuItem
  257. key={item.label}
  258. onClick={item.handler}
  259. sx={{ minWidth: 120 }}
  260. dense
  261. >
  262. {t(item.label)}
  263. </MenuItem>
  264. ))}
  265. </Menu>
  266. <InfoViewer
  267. open={editOpen}
  268. itemData={itemData}
  269. onClose={() => setEditOpen(false)}
  270. />
  271. <EditorViewer
  272. uid={uid}
  273. open={fileOpen}
  274. mode="yaml"
  275. onClose={() => setFileOpen(false)}
  276. />
  277. </>
  278. );
  279. };
  280. function parseUrl(url?: string) {
  281. if (!url) return "";
  282. const regex = /https?:\/\/(.+?)\//;
  283. const result = url.match(regex);
  284. return result ? result[1] : "local file";
  285. }
  286. function parseExpire(expire?: number) {
  287. if (!expire) return "-";
  288. return dayjs(expire * 1000).format("YYYY-MM-DD");
  289. }