2022-11-20 22:37:34 +08:00

328 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import dayjs from "dayjs";
import { mutate } from "swr";
import { useEffect, useState } from "react";
import { useLockFn } from "ahooks";
import { useRecoilState } from "recoil";
import { useTranslation } from "react-i18next";
import {
Box,
Typography,
LinearProgress,
IconButton,
keyframes,
MenuItem,
Menu,
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { atomLoadingCache } from "@/services/states";
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
import { Notice } from "@/components/base";
import { InfoViewer } from "./info-viewer";
import { EditorViewer } from "./editor-viewer";
import { ProfileBox } from "./profile-box";
import parseTraffic from "@/utils/parse-traffic";
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
interface Props {
selected: boolean;
itemData: IProfileItem;
onSelect: (force: boolean) => void;
}
export const ProfileItem = (props: Props) => {
const { selected, itemData, onSelect } = props;
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
const { uid, name = "Profile", extra, updated = 0 } = itemData;
// local file mode
// remote file mode
const hasUrl = !!itemData.url;
const hasExtra = !!extra; // only subscription url has extra info
const { upload = 0, download = 0, total = 0 } = extra ?? {};
const from = parseUrl(itemData.url);
const expire = parseExpire(extra?.expire);
const progress = Math.round(((download + upload) * 100) / (total + 0.1));
const loading = loadingCache[itemData.uid] ?? false;
// interval update from now field
const [, setRefresh] = useState({});
useEffect(() => {
if (!hasUrl) return;
let timer: any = null;
const handler = () => {
const now = Date.now();
const lastUpdate = updated * 1000;
// 大于一天的ä¸<C3A4>管
if (now - lastUpdate >= 24 * 36e5) return;
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
timer = setTimeout(() => {
setRefresh({});
handler();
}, wait);
};
handler();
return () => {
if (timer) clearTimeout(timer);
};
}, [hasUrl, updated]);
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false);
const onEditInfo = () => {
setAnchorEl(null);
setEditOpen(true);
};
const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true);
};
const onForceSelect = () => {
setAnchorEl(null);
onSelect(true);
};
const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
try {
await viewProfile(itemData.uid);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
});
/// 0 ä¸<C3A4>使用任何代ç<C2A3>
/// 1 使用é…<C3A9>置好的代ç<C2A3>
/// 2 至å°ä½¿ç”¨ä¸€ä¸ªä»£ç<C2A3>†ï¼Œæ ¹æ<C2B9>®é…<C3A9>ç½®ï¼Œå¦æžœæ²¡é…<C3A9>置,默认使用系统代ç<C2A3>
const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
setAnchorEl(null);
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
const option: Partial<IProfileOption> = {};
if (type === 0) {
option.with_proxy = false;
option.self_proxy = false;
} else if (type === 1) {
// nothing
} else if (type === 2) {
if (itemData.option?.self_proxy) {
option.with_proxy = false;
option.self_proxy = true;
} else {
option.with_proxy = true;
option.self_proxy = false;
}
}
try {
await updateProfile(itemData.uid, option);
mutate("getProfiles");
} catch (err: any) {
const errmsg = err?.message || err.toString();
Notice.error(
errmsg.replace(/error sending request for url (\S+?): /, "")
);
} finally {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
}
});
const onDelete = useLockFn(async () => {
setAnchorEl(null);
try {
await deleteProfile(itemData.uid);
mutate("getProfiles");
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
});
const urlModeMenu = [
{ label: "Select", handler: onForceSelect },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Update", handler: () => onUpdate(0) },
{ label: "Update(Proxy)", handler: () => onUpdate(2) },
{ label: "Delete", handler: onDelete },
];
const fileModeMenu = [
{ label: "Select", handler: onForceSelect },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Delete", handler: onDelete },
];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
return (
<>
<ProfileBox
aria-selected={selected}
onClick={() => onSelect(false)}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
event.preventDefault();
}}
>
<Box position="relative">
<Typography
width="calc(100% - 36px)"
variant="h6"
component="h2"
noWrap
title={name}
>
{name}
</Typography>
{/* only if has url can it be updated */}
{hasUrl && (
<IconButton
sx={{
position: "absolute",
p: "3px",
top: -1,
right: -5,
animation: loading ? `1s linear infinite ${round}` : "none",
}}
size="small"
color="inherit"
disabled={loading}
onClick={(e) => {
e.stopPropagation();
onUpdate(1);
}}
>
<RefreshRounded color="inherit" />
</IconButton>
)}
</Box>
{/* the second line show url's info or description */}
<Box sx={boxStyle}>
{hasUrl ? (
<>
<Typography noWrap title={`From: ${from}`}>
{from}
</Typography>
<Typography
noWrap
flex="1 0 auto"
fontSize={14}
textAlign="right"
title={`Updated Time: ${parseExpire(updated)}`}
>
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
</Typography>
</>
) : (
<Typography noWrap title={itemData.desc}>
{itemData.desc}
</Typography>
)}
</Box>
{/* the third line show extra info or last updated time */}
{hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title="Used / Total">
{parseTraffic(upload + download)} / {parseTraffic(total)}
</span>
<span title="Expire Time">{expire}</span>
</Box>
) : (
<Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
<span title="Updated Time">{parseExpire(updated)}</span>
</Box>
)}
<LinearProgress
variant="determinate"
value={progress}
color="inherit"
/>
</ProfileBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={{ minWidth: 120 }}
dense
>
{t(item.label)}
</MenuItem>
))}
</Menu>
<InfoViewer
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
<EditorViewer
uid={uid}
open={fileOpen}
mode="yaml"
onClose={() => setFileOpen(false)}
/>
</>
);
};
function parseUrl(url?: string) {
if (!url) return "";
const regex = /https?:\/\/(.+?)\//;
const result = url.match(regex);
return result ? result[1] : "local file";
}
function parseExpire(expire?: number) {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
}