import dayjs from "dayjs"; import { mutate } from "swr"; import { useEffect, useState } from "react"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Box, Typography, LinearProgress, IconButton, keyframes, MenuItem, Menu, CircularProgress, } from "@mui/material"; import { RefreshRounded, DragIndicatorRounded } from "@mui/icons-material"; import { useLoadingCache, useSetLoadingCache } from "@/services/states"; import { viewProfile, readProfileFile, updateProfile, saveProfileFile, } from "@/services/cmds"; import { Notice } from "@/components/base"; import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer"; import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { ProfileBox } from "./profile-box"; import parseTraffic from "@/utils/parse-traffic"; import { ConfirmViewer } from "@/components/profile/confirm-viewer"; import { open } from "@tauri-apps/plugin-shell"; import { ProxiesEditorViewer } from "./proxies-editor-viewer"; const round = keyframes` from { transform: rotate(0deg); } to { transform: rotate(360deg); } `; interface Props { id: string; selected: boolean; activating: boolean; itemData: IProfileItem; onSelect: (force: boolean) => void; onEdit: () => void; onSave?: (prev?: string, curr?: string) => void; onDelete: () => void; } export const ProfileItem = (props: Props) => { const { selected, activating, itemData, onSelect, onEdit, onSave, onDelete } = props; const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: props.id }); const { t } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); const [position, setPosition] = useState({ left: 0, top: 0 }); const loadingCache = useLoadingCache(); const setLoadingCache = useSetLoadingCache(); const { uid, name = "Profile", extra, updated = 0, option } = itemData; // local file mode // remote file mode // remote file mode const hasUrl = !!itemData.url; const hasExtra = !!extra; // only subscription url has extra info const hasHome = !!itemData.home; // only subscription url has home page const { upload = 0, download = 0, total = 0 } = extra ?? {}; const from = parseUrl(itemData.url); const description = itemData.desc; const expire = parseExpire(extra?.expire); const progress = Math.min( Math.round(((download + upload) * 100) / (total + 0.01)) + 1, 100, ); const loading = loadingCache[itemData.uid] ?? false; // interval update fromNow field const [, setRefresh] = useState({}); useEffect(() => { if (!hasUrl) return; let timer: any = null; const handler = () => { const now = Date.now(); const lastUpdate = updated * 1000; // 大于一天的不管 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 [fileOpen, setFileOpen] = useState(false); const [rulesOpen, setRulesOpen] = useState(false); const [proxiesOpen, setProxiesOpen] = useState(false); const [groupsOpen, setGroupsOpen] = useState(false); const [mergeOpen, setMergeOpen] = useState(false); const [scriptOpen, setScriptOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const onOpenHome = () => { setAnchorEl(null); open(itemData.home ?? ""); }; const onEditInfo = () => { setAnchorEl(null); onEdit(); }; const onEditFile = () => { setAnchorEl(null); setFileOpen(true); }; const onEditRules = () => { setAnchorEl(null); setRulesOpen(true); }; const onEditProxies = () => { setAnchorEl(null); setProxiesOpen(true); }; const onEditGroups = () => { setAnchorEl(null); setGroupsOpen(true); }; const onEditMerge = () => { setAnchorEl(null); setMergeOpen(true); }; const onEditScript = () => { setAnchorEl(null); setScriptOpen(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 不使用任何代理 /// 1 使用订阅好的代理 /// 2 至少使用一个代理,根据订阅,如果没订阅,默认使用系统代理 const onUpdate = useLockFn(async (type: 0 | 1 | 2) => { setAnchorEl(null); setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })); const option: Partial = {}; 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); Notice.success(t("Update subscription successfully")); 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 urlModeMenu = ( hasHome ? [{ label: "Home", handler: onOpenHome, disabled: false }] : [] ).concat([ { label: "Select", handler: onForceSelect, disabled: false }, { label: "Edit Info", handler: onEditInfo, disabled: false }, { label: "Edit File", handler: onEditFile, disabled: false }, { label: "Edit Rules", handler: onEditRules, disabled: !option?.rules, }, { label: "Edit Proxies", handler: onEditProxies, disabled: !option?.proxies, }, { label: "Edit Groups", handler: onEditGroups, disabled: !option?.groups, }, { label: "Extend Config", handler: onEditMerge, disabled: !option?.merge, }, { label: "Extend Script", handler: onEditScript, disabled: !option?.script, }, { label: "Open File", handler: onOpenFile, disabled: false }, { label: "Update", handler: () => onUpdate(0), disabled: false }, { label: "Update(Proxy)", handler: () => onUpdate(2), disabled: false }, { label: "Delete", handler: () => { setAnchorEl(null); setConfirmOpen(true); }, disabled: false, }, ]); const fileModeMenu = [ { label: "Select", handler: onForceSelect, disabled: false }, { label: "Edit Info", handler: onEditInfo, disabled: false }, { label: "Edit File", handler: onEditFile, disabled: false }, { label: "Edit Rules", handler: onEditRules, disabled: !option?.rules, }, { label: "Edit Proxies", handler: onEditProxies, disabled: !option?.proxies, }, { label: "Edit Groups", handler: onEditGroups, disabled: !option?.groups, }, { label: "Extend Config", handler: onEditMerge, disabled: !option?.merge, }, { label: "Extend Script", handler: onEditScript, disabled: !option?.script, }, { label: "Open File", handler: onOpenFile, disabled: false }, { label: "Delete", handler: () => { setAnchorEl(null); setConfirmOpen(true); }, disabled: false, }, ]; const boxStyle = { height: 26, display: "flex", alignItems: "center", justifyContent: "space-between", }; return ( onSelect(false)} onContextMenu={(event) => { const { clientX, clientY } = event; setPosition({ top: clientY, left: clientX }); setAnchorEl(event.currentTarget); event.preventDefault(); }} > {activating && ( )} { return { color: text.primary }; }, ]} /> {name} {/* only if has url can it be updated */} {hasUrl && ( { e.stopPropagation(); onUpdate(1); }} > )} {/* the second line show url's info or description */} { <> {description ? ( {description} ) : ( hasUrl && ( {from} ) )} {hasUrl && ( {updated > 0 ? dayjs(updated * 1000).fromNow() : ""} )} } {/* the third line show extra info or last updated time */} {hasExtra ? ( {parseTraffic(upload + download)} / {parseTraffic(total)} {expire} ) : ( {parseExpire(updated)} )} 0 ? 1 : 0 }} /> setAnchorEl(null)} anchorPosition={position} anchorReference="anchorPosition" transitionDuration={225} MenuListProps={{ sx: { py: 0.5 } }} onContextMenu={(e) => { setAnchorEl(null); e.preventDefault(); }} > {(hasUrl ? urlModeMenu : fileModeMenu).map((item) => ( { return { color: item.label === "Delete" ? theme.palette.error.main : undefined, }; }, ]} dense > {t(item.label)} ))} {fileOpen && ( { await saveProfileFile(uid, curr ?? ""); onSave && onSave(prev, curr); }} onClose={() => setFileOpen(false)} /> )} {rulesOpen && ( setRulesOpen(false)} /> )} {proxiesOpen && ( setProxiesOpen(false)} /> )} {groupsOpen && ( { setGroupsOpen(false); }} /> )} {mergeOpen && ( { await saveProfileFile(option?.merge ?? "", curr ?? ""); onSave && onSave(prev, curr); }} onClose={() => setMergeOpen(false)} /> )} {scriptOpen && ( { await saveProfileFile(option?.script ?? "", curr ?? ""); onSave && onSave(prev, curr); }} onClose={() => setScriptOpen(false)} /> )} setConfirmOpen(false)} onConfirm={() => { onDelete(); setConfirmOpen(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"); }