profile-item.tsx 9.3 KB

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