import useSWR from "swr"; import { useEffect, useMemo, useRef, useState } from "react"; import { useLockFn } from "ahooks"; import { Box, Button, IconButton, Stack, Divider, Grid2 } from "@mui/material"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { LoadingButton } from "@mui/lab"; import { ClearRounded, ContentPasteRounded, LocalFireDepartmentRounded, RefreshRounded, TextSnippetOutlined, } from "@mui/icons-material"; import { useTranslation } from "react-i18next"; import { importProfile, enhanceProfiles, restartCore, getRuntimeLogs, deleteProfile, updateProfile, reorderProfile, createProfile, } from "@/services/cmds"; import { useSetLoadingCache, useThemeMode } from "@/services/states"; import { closeAllConnections } from "@/services/api"; import { BasePage, DialogRef, Notice } from "@/components/base"; import { ProfileViewer, ProfileViewerRef, } from "@/components/profile/profile-viewer"; import { ProfileMore } from "@/components/profile/profile-more"; import { ProfileItem } from "@/components/profile/profile-item"; import { useProfiles } from "@/hooks/use-profiles"; import { ConfigViewer } from "@/components/setting/mods/config-viewer"; import { add, throttle } from "lodash-es"; import { BaseStyledTextField } from "@/components/base/base-styled-text-field"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { useLocation } from "react-router-dom"; import { useListen } from "@/hooks/use-listen"; import { listen } from "@tauri-apps/api/event"; import { TauriEvent } from "@tauri-apps/api/event"; const ProfilePage = () => { const { t } = useTranslation(); const location = useLocation(); const { addListener } = useListen(); const [url, setUrl] = useState(""); const [disabled, setDisabled] = useState(false); const [activatings, setActivatings] = useState([]); const [loading, setLoading] = useState(false); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const { current } = location.state || {}; useEffect(() => { const handleFileDrop = async () => { const unlisten = await addListener( TauriEvent.DRAG_DROP, async (event: any) => { const paths = event.payload.paths; for (let file of paths) { if (!file.endsWith(".yaml") && !file.endsWith(".yml")) { Notice.error(t("Only YAML Files Supported")); continue; } const item = { type: "local", name: file.split(/\/|\\/).pop() ?? "New Profile", desc: "", url: "", option: { with_proxy: false, self_proxy: false, }, } as IProfileItem; let data = await readTextFile(file); await createProfile(item, data); await mutateProfiles(); } }, ); return unlisten; }; const unsubscribe = handleFileDrop(); return () => { unsubscribe.then((cleanup) => cleanup()); }; }, []); const { profiles = {}, activateSelected, patchProfiles, mutateProfiles, } = useProfiles(); const { data: chainLogs = {}, mutate: mutateLogs } = useSWR( "getRuntimeLogs", getRuntimeLogs, ); const viewerRef = useRef(null); const configRef = useRef(null); // distinguish type const profileItems = useMemo(() => { const items = profiles.items || []; const type1 = ["local", "remote"]; return items.filter((i) => i && type1.includes(i.type!)); }, [profiles]); const currentActivatings = () => { return [...new Set([profiles.current ?? ""])].filter(Boolean); }; const onImport = async () => { if (!url) return; setLoading(true); try { await importProfile(url); Notice.success(t("Profile Imported Successfully")); setUrl(""); setLoading(false); mutateProfiles(); await onEnhance(false); } catch (err: any) { Notice.error(err.message || err.toString()); setLoading(false); } finally { setDisabled(false); setLoading(false); } }; const onDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (over) { if (active.id !== over.id) { await reorderProfile(active.id.toString(), over.id.toString()); mutateProfiles(); } } }; const activateProfile = async (profile: string, notifySuccess: boolean) => { // 避免大多数情况下loading态闪烁 const reset = setTimeout(() => { setActivatings((prev) => [...prev, profile]); }, 100); try { await patchProfiles({ current: profile }); await mutateLogs(); closeAllConnections(); await activateSelected(); if (notifySuccess) { Notice.success(t("Profile Switched"), 1000); } } catch (err: any) { Notice.error(err?.message || err.toString(), 4000); } finally { clearTimeout(reset); setActivatings([]); } }; const onSelect = useLockFn(async (current: string, force: boolean) => { if (!force && current === profiles.current) return; await activateProfile(current, true); }); useEffect(() => { (async () => { if (current) { mutateProfiles(); await activateProfile(current, false); } })(); }, current); const onEnhance = useLockFn(async (notifySuccess: boolean) => { setActivatings(currentActivatings()); try { await enhanceProfiles(); mutateLogs(); if (notifySuccess) { Notice.success(t("Profile Reactivated"), 1000); } } catch (err: any) { Notice.error(err.message || err.toString(), 3000); } finally { setActivatings([]); } }); const onDelete = useLockFn(async (uid: string) => { const current = profiles.current === uid; try { setActivatings([...(current ? currentActivatings() : []), uid]); await deleteProfile(uid); mutateProfiles(); mutateLogs(); current && (await onEnhance(false)); } catch (err: any) { Notice.error(err?.message || err.toString()); } finally { setActivatings([]); } }); // 更新所有订阅 const setLoadingCache = useSetLoadingCache(); const onUpdateAll = useLockFn(async () => { const throttleMutate = throttle(mutateProfiles, 2000, { trailing: true, }); const updateOne = async (uid: string) => { try { await updateProfile(uid); throttleMutate(); } finally { setLoadingCache((cache) => ({ ...cache, [uid]: false })); } }; return new Promise((resolve) => { setLoadingCache((cache) => { // 获取没有正在更新的订阅 const items = profileItems.filter( (e) => e.type === "remote" && !cache[e.uid], ); const change = Object.fromEntries(items.map((e) => [e.uid, true])); Promise.allSettled(items.map((e) => updateOne(e.uid))).then(resolve); return { ...cache, ...change }; }); }); }); const onCopyLink = async () => { const text = await readText(); if (text) setUrl(text); }; const mode = useThemeMode(); const islight = mode === "light" ? true : false; const dividercolor = islight ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)"; return ( configRef.current?.open()} > onEnhance(true)} > } > setUrl(e.target.value)} placeholder={t("Profile URL")} InputProps={{ sx: { pr: 1 }, endAdornment: !url ? ( ) : ( setUrl("")} > ), }} /> {t("Import")} { return x.uid; })} > {profileItems.map((item) => ( onSelect(item.uid, f)} onEdit={() => viewerRef.current?.edit(item)} onSave={async (prev, curr) => { if (prev !== curr && profiles.current === item.uid) { await restartCore(); Notice.success(t("Clash Core Restarted"), 1000); } }} onDelete={() => onDelete(item.uid)} /> ))} { if (prev !== curr) { await onEnhance(false); } }} /> { if (prev !== curr) { await onEnhance(false); } }} /> { mutateProfiles(); await onEnhance(false); }} /> ); }; export default ProfilePage;