import { closestCenter, DndContext, DragEndEvent, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { ClearRounded, ContentPasteRounded, LocalFireDepartmentRounded, RefreshRounded, TextSnippetOutlined, CheckBoxOutlineBlankRounded, CheckBoxRounded, IndeterminateCheckBoxRounded, DeleteRounded, } from "@mui/icons-material"; import { LoadingButton } from "@mui/lab"; import { Box, Button, Divider, Grid, IconButton, Stack } from "@mui/material"; import { listen, TauriEvent } from "@tauri-apps/api/event"; import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { useLockFn } from "ahooks"; import { throttle } from "lodash-es"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import useSWR, { mutate } from "swr"; import { closeAllConnections } from "tauri-plugin-mihomo-api"; import { BasePage, DialogRef } from "@/components/base"; import { BaseStyledTextField } from "@/components/base/base-styled-text-field"; import { ProfileItem } from "@/components/profile/profile-item"; import { ProfileMore } from "@/components/profile/profile-more"; import { ProfileViewer, ProfileViewerRef, } from "@/components/profile/profile-viewer"; import { ConfigViewer } from "@/components/setting/mods/config-viewer"; import { useListen } from "@/hooks/use-listen"; import { useProfiles } from "@/hooks/use-profiles"; import { createProfile, deleteProfile, enhanceProfiles, getProfiles, //restartCore, getRuntimeLogs, importProfile, reorderProfile, updateProfile, } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import { useSetLoadingCache, useThemeMode } from "@/services/states"; // 记录profile切换状态 const debugProfileSwitch = (action: string, profile: string, extra?: any) => { const timestamp = new Date().toISOString().substring(11, 23); console.log( `[Profile-Debug][${timestamp}] ${action}: ${profile}`, extra || "", ); }; // 检查请求是否已过期 const isRequestOutdated = ( currentSequence: number, requestSequenceRef: any, profile: string, ) => { if (currentSequence !== requestSequenceRef.current) { debugProfileSwitch( "REQUEST_OUTDATED", profile, `当前序列号: ${currentSequence}, 最新序列号: ${requestSequenceRef.current}`, ); return true; } return false; }; // 检查是否被中断 const isOperationAborted = ( abortController: AbortController, profile: string, ) => { if (abortController.signal.aborted) { debugProfileSwitch("OPERATION_ABORTED", profile); return true; } return false; }; const normalizeProfileUrl = (value?: string) => { if (!value) return ""; const trimmed = value.trim(); try { const url = new URL(trimmed); const auth = url.username || url.password ? `${url.username}${url.password ? `:${url.password}` : ""}@` : ""; const normalized = `${url.protocol.toLowerCase()}//${auth}${url.hostname.toLowerCase()}` + `${url.port ? `:${url.port}` : ""}${url.pathname}${url.search}${url.hash}`; return normalized.replace(/\/+$/, ""); } catch { const schemeNormalized = trimmed.replace( /^([a-z]+):\/\//i, (match, scheme: string) => `${scheme.toLowerCase()}://`, ); return schemeNormalized.replace(/\/+$/, ""); } }; const getProfileSignature = (profile?: IProfileItem | null) => { if (!profile) return ""; const { extra, selected, option, name, desc } = profile; return JSON.stringify({ extra: extra ?? null, selected: selected ?? null, option: option ?? null, name: name ?? null, desc: desc ?? null, }); }; type ImportLandingVerifier = { baselineCount: number; hasLanding: (config?: IProfilesConfig | null) => boolean; }; const createImportLandingVerifier = ( items: IProfileItem[] | undefined, url: string, ): ImportLandingVerifier => { const normalizedUrl = normalizeProfileUrl(url); const baselineCount = items?.length ?? 0; const baselineProfile = normalizedUrl ? items?.find((item) => normalizeProfileUrl(item?.url) === normalizedUrl) : undefined; const baselineSignature = getProfileSignature(baselineProfile); const baselineUpdated = baselineProfile?.updated ?? 0; const hadBaselineProfile = Boolean(baselineProfile); const hasLanding = (config?: IProfilesConfig | null) => { const currentItems = config?.items ?? []; const currentCount = currentItems.length; if (currentCount > baselineCount) { console.log( `[导入验证] 配置数量已增加: ${baselineCount} -> ${currentCount}`, ); return true; } if (!normalizedUrl) { return false; } const matchingProfile = currentItems.find( (item) => normalizeProfileUrl(item?.url) === normalizedUrl, ); if (!matchingProfile) { return false; } if (!hadBaselineProfile) { console.log("[导入验证] 检测到新的订阅记录,判定为导入成功"); return true; } const currentSignature = getProfileSignature(matchingProfile); const currentUpdated = matchingProfile.updated ?? 0; if (currentUpdated > baselineUpdated) { console.log( `[导入验证] 订阅更新时间已更新 ${baselineUpdated} -> ${currentUpdated}`, ); return true; } if (currentSignature !== baselineSignature) { console.log("[导入验证] 订阅详情发生变化,判定为导入成功"); return true; } return false; }; return { baselineCount, hasLanding, }; }; 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); // Batch selection states const [batchMode, setBatchMode] = useState(false); const [selectedProfiles, setSelectedProfiles] = useState>( new Set(), ); // 防止重复切换 const switchingProfileRef = useRef(null); // 支持中断当前切换操作 const abortControllerRef = useRef(null); // 只处理最新的切换请求 const requestSequenceRef = useRef(0); // 待处理请求跟踪,取消排队的请求 const pendingRequestRef = useRef | null>(null); // 处理profile切换中断 const handleProfileInterrupt = useCallback( (previousSwitching: string, newProfile: string) => { debugProfileSwitch( "INTERRUPT_PREVIOUS", previousSwitching, `被 ${newProfile} 中断`, ); if (abortControllerRef.current) { abortControllerRef.current.abort(); debugProfileSwitch("ABORT_CONTROLLER_TRIGGERED", previousSwitching); } if (pendingRequestRef.current) { debugProfileSwitch("CANCEL_PENDING_REQUEST", previousSwitching); } setActivatings((prev) => prev.filter((id) => id !== previousSwitching)); showNotice( "info", `${t("Profile switch interrupted by new selection")}: ${previousSwitching} → ${newProfile}`, 3000, ); }, [t], ); // 清理切换状态 const cleanupSwitchState = useCallback( (profile: string, sequence: number) => { setActivatings((prev) => prev.filter((id) => id !== profile)); switchingProfileRef.current = null; abortControllerRef.current = null; pendingRequestRef.current = null; debugProfileSwitch("SWITCH_END", profile, `序列号: ${sequence}`); }, [], ); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const { current } = location.state || {}; const { profiles = {}, activateSelected, patchProfiles, mutateProfiles, error, isStale, } = useProfiles(); useEffect(() => { const handleFileDrop = async () => { const unlisten = await addListener( TauriEvent.DRAG_DROP, async (event: any) => { const paths = event.payload.paths; for (const file of paths) { if (!file.endsWith(".yaml") && !file.endsWith(".yml")) { showNotice("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; const data = await readTextFile(file); await createProfile(item, data); await mutateProfiles(); } }, ); return unlisten; }; const unsubscribe = handleFileDrop(); return () => { unsubscribe.then((cleanup) => cleanup()); }; }, [addListener, mutateProfiles, t]); // 添加紧急恢复功能 const onEmergencyRefresh = useLockFn(async () => { console.log("[紧急刷新] 开始强制刷新所有数据"); try { // 清除所有SWR缓存 await mutate(() => true, undefined, { revalidate: false }); // 强制重新获取配置数据 await mutateProfiles(undefined, { revalidate: true, rollbackOnError: false, }); // 等待状态稳定后增强配置 await new Promise((resolve) => setTimeout(resolve, 500)); await onEnhance(false); showNotice("success", "数据已强制刷新", 2000); } catch (error: any) { console.error("[紧急刷新] 失败:", error); showNotice("error", `紧急刷新失败: ${error.message}`, 4000); } }); 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; // 校验url是否为http/https if (!/^https?:\/\//i.test(url)) { showNotice("error", t("Invalid Profile URL")); return; } setLoading(true); const importVerifier = createImportLandingVerifier(profiles?.items, url); const handleImportSuccess = async (noticeKey: string) => { showNotice("success", t(noticeKey)); setUrl(""); await performRobustRefresh(importVerifier); }; const waitForImportLanding = async () => { const maxChecks = 2; for (let attempt = 0; attempt <= maxChecks; attempt++) { try { const currentProfiles = await getProfiles(); if (importVerifier.hasLanding(currentProfiles)) { return true; } if (attempt < maxChecks) { await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)), ); } } catch (verifyErr) { console.warn("[导入验证] 获取配置状态失败:", verifyErr); break; } } return false; }; try { // 尝试正常导入 await importProfile(url); await handleImportSuccess("Profile Imported Successfully"); return; } catch (initialErr) { console.warn("[订阅导入] 首次导入失败:", initialErr); const alreadyImported = await waitForImportLanding(); if (alreadyImported) { console.warn( "[订阅导入] 接口返回失败,但检测到订阅已导入,跳过回退导入流程", ); await handleImportSuccess("Profile Imported Successfully"); return; } // 首次导入失败且未检测到数据变更,尝试使用自身代理 showNotice("info", t("Import failed, retrying with Clash proxy...")); try { // 使用自身代理尝试导入 await importProfile(url, { with_proxy: false, self_proxy: true, }); await handleImportSuccess("Profile Imported with Clash proxy"); } catch (retryErr: any) { // 回退导入也失败 const retryErrmsg = retryErr?.message || retryErr.toString(); showNotice( "error", `${t("Import failed even with Clash proxy")}: ${retryErrmsg}`, ); } } finally { setDisabled(false); setLoading(false); } }; // 强化的刷新策略 const performRobustRefresh = async ( importVerifier: ImportLandingVerifier, ) => { const { baselineCount, hasLanding } = importVerifier; let retryCount = 0; const maxRetries = 5; const baseDelay = 200; while (retryCount < maxRetries) { try { console.log(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`); // 强制刷新,绕过所有缓存 await mutateProfiles(undefined, { revalidate: true, rollbackOnError: false, }); // 等待状态稳定 await new Promise((resolve) => setTimeout(resolve, baseDelay * (retryCount + 1)), ); // 验证刷新是否成功 const currentProfiles = await getProfiles(); const currentCount = currentProfiles?.items?.length || 0; if (currentCount > baselineCount) { console.log( `[导入刷新] 配置刷新成功,配置数量 ${baselineCount} -> ${currentCount}`, ); await onEnhance(false); return; } if (hasLanding(currentProfiles)) { console.log("[导入刷新] 检测到订阅内容更新,判定刷新成功"); await onEnhance(false); return; } console.warn( `[导入刷新] 配置数量未增加 (${currentCount}), 继续重试...`, ); retryCount++; } catch (error) { console.error(`[导入刷新] 第${retryCount + 1}次刷新失败:`, error); retryCount++; await new Promise((resolve) => setTimeout(resolve, baseDelay * retryCount), ); } } // 所有重试失败后的最后尝试 console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`); try { // 清除SWR缓存并重新获取 await mutate("getProfiles", getProfiles(), { revalidate: true }); await onEnhance(false); showNotice( "error", t("Profile imported but may need manual refresh"), 3000, ); } catch (finalError) { console.error(`[导入刷新] 最终刷新尝试失败:`, finalError); showNotice( "error", t("Profile imported successfully, please restart if not visible"), 5000, ); } }; 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 executeBackgroundTasks = useCallback( async ( profile: string, sequence: number, abortController: AbortController, ) => { try { if ( sequence === requestSequenceRef.current && switchingProfileRef.current === profile && !abortController.signal.aborted ) { await activateSelected(); console.log(`[Profile] 后台处理完成,序列号: ${sequence}`); } else { debugProfileSwitch( "BACKGROUND_TASK_SKIPPED", profile, `序列号过期或被中断: ${sequence} vs ${requestSequenceRef.current}`, ); } } catch (err: any) { console.warn("Failed to activate selected proxies:", err); } }, [activateSelected], ); const activateProfile = useCallback( async (profile: string, notifySuccess: boolean) => { if (profiles.current === profile && !notifySuccess) { console.log( `[Profile] 目标profile ${profile} 已经是当前配置,跳过切换`, ); return; } const currentSequence = ++requestSequenceRef.current; debugProfileSwitch("NEW_REQUEST", profile, `序列号: ${currentSequence}`); // 处理中断逻辑 const previousSwitching = switchingProfileRef.current; if (previousSwitching && previousSwitching !== profile) { handleProfileInterrupt(previousSwitching, profile); } // 防止重复切换同一个profile if (switchingProfileRef.current === profile) { debugProfileSwitch("DUPLICATE_SWITCH_BLOCKED", profile); return; } // 初始化切换状态 switchingProfileRef.current = profile; debugProfileSwitch("SWITCH_START", profile, `序列号: ${currentSequence}`); const currentAbortController = new AbortController(); abortControllerRef.current = currentAbortController; setActivatings((prev) => { if (prev.includes(profile)) return prev; return [...prev, profile]; }); try { console.log( `[Profile] 开始切换到: ${profile},序列号: ${currentSequence}`, ); // 检查请求有效性 if ( isRequestOutdated(currentSequence, requestSequenceRef, profile) || isOperationAborted(currentAbortController, profile) ) { return; } // 执行切换请求 const requestPromise = patchProfiles( { current: profile }, currentAbortController.signal, ); pendingRequestRef.current = requestPromise; const success = await requestPromise; if (pendingRequestRef.current === requestPromise) { pendingRequestRef.current = null; } // 再次检查有效性 if ( isRequestOutdated(currentSequence, requestSequenceRef, profile) || isOperationAborted(currentAbortController, profile) ) { return; } // 完成切换 await mutateLogs(); closeAllConnections(); if (notifySuccess && success) { showNotice("success", t("Profile Switched"), 1000); } console.log( `[Profile] 切换到 ${profile} 完成,序列号: ${currentSequence},开始后台处理`, ); // 延迟执行后台任务 setTimeout( () => executeBackgroundTasks( profile, currentSequence, currentAbortController, ), 50, ); } catch (err: any) { if (pendingRequestRef.current) { pendingRequestRef.current = null; } // 检查是否因为中断或过期而出错 if ( isOperationAborted(currentAbortController, profile) || isRequestOutdated(currentSequence, requestSequenceRef, profile) ) { return; } console.error(`[Profile] 切换失败:`, err); showNotice("error", err?.message || err.toString(), 4000); } finally { // 只有当前profile仍然是正在切换的profile且序列号匹配时才清理状态 if ( switchingProfileRef.current === profile && currentSequence === requestSequenceRef.current ) { cleanupSwitchState(profile, currentSequence); } else { debugProfileSwitch( "CLEANUP_SKIPPED", profile, `序列号不匹配或已被接管: ${currentSequence} vs ${requestSequenceRef.current}`, ); } } }, [ profiles, patchProfiles, mutateLogs, t, executeBackgroundTasks, handleProfileInterrupt, cleanupSwitchState, ], ); const onSelect = async (current: string, force: boolean) => { // 阻止重复点击或已激活的profile if (switchingProfileRef.current === current) { debugProfileSwitch("DUPLICATE_CLICK_IGNORED", current); return; } if (!force && current === profiles.current) { debugProfileSwitch("ALREADY_CURRENT_IGNORED", current); return; } await activateProfile(current, true); }; useEffect(() => { (async () => { if (current) { mutateProfiles(); await activateProfile(current, false); } })(); }, [current, activateProfile, mutateProfiles]); const onEnhance = useLockFn(async (notifySuccess: boolean) => { if (switchingProfileRef.current) { console.log( `[Profile] 有profile正在切换中(${switchingProfileRef.current}),跳过enhance操作`, ); return; } const currentProfiles = currentActivatings(); setActivatings((prev) => [...new Set([...prev, ...currentProfiles])]); try { await enhanceProfiles(); mutateLogs(); if (notifySuccess) { showNotice("success", t("Profile Reactivated"), 1000); } } catch (err: any) { showNotice("error", err.message || err.toString(), 3000); } finally { // 保留正在切换的profile,清除其他状态 setActivatings((prev) => prev.filter((id) => id === switchingProfileRef.current), ); } }); const onDelete = useLockFn(async (uid: string) => { const current = profiles.current === uid; try { setActivatings([...(current ? currentActivatings() : []), uid]); await deleteProfile(uid); mutateProfiles(); mutateLogs(); if (current) { await onEnhance(false); } } catch (err: any) { showNotice("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(); } catch (err: any) { console.error(`更新订阅 ${uid} 失败:`, err); } 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); }; // Batch selection functions const toggleBatchMode = () => { setBatchMode(!batchMode); if (!batchMode) { // Entering batch mode - clear previous selections setSelectedProfiles(new Set()); } }; const toggleProfileSelection = (uid: string) => { setSelectedProfiles((prev) => { const newSet = new Set(prev); if (newSet.has(uid)) { newSet.delete(uid); } else { newSet.add(uid); } return newSet; }); }; const selectAllProfiles = () => { setSelectedProfiles(new Set(profileItems.map((item) => item.uid))); }; const clearAllSelections = () => { setSelectedProfiles(new Set()); }; const isAllSelected = () => { return ( profileItems.length > 0 && profileItems.length === selectedProfiles.size ); }; const getSelectionState = () => { if (selectedProfiles.size === 0) { return "none"; // 无选择 } else if (selectedProfiles.size === profileItems.length) { return "all"; // 全选 } else { return "partial"; // 部分选择 } }; const deleteSelectedProfiles = useLockFn(async () => { if (selectedProfiles.size === 0) return; try { // Get all currently activating profiles const currentActivating = profiles.current && selectedProfiles.has(profiles.current) ? [profiles.current] : []; setActivatings((prev) => [...new Set([...prev, ...currentActivating])]); // Delete all selected profiles for (const uid of selectedProfiles) { await deleteProfile(uid); } await mutateProfiles(); await mutateLogs(); // If any deleted profile was current, enhance profiles if (currentActivating.length > 0) { await onEnhance(false); } // Clear selections and exit batch mode setSelectedProfiles(new Set()); setBatchMode(false); showNotice("success", t("Selected profiles deleted successfully")); } catch (err: any) { showNotice("error", err?.message || err.toString()); } finally { setActivatings([]); } }); const mode = useThemeMode(); const islight = mode === "light" ? true : false; const dividercolor = islight ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)"; // 监听后端配置变更 useEffect(() => { let unlistenPromise: Promise<() => void> | undefined; let lastProfileId: string | null = null; let lastUpdateTime = 0; const debounceDelay = 200; const setupListener = async () => { unlistenPromise = listen("profile-changed", (event) => { const newProfileId = event.payload; const now = Date.now(); console.log(`[Profile] 收到配置变更事件: ${newProfileId}`); if ( lastProfileId === newProfileId && now - lastUpdateTime < debounceDelay ) { console.log(`[Profile] 重复事件被防抖,跳过`); return; } lastProfileId = newProfileId; lastUpdateTime = now; console.log(`[Profile] 执行配置数据刷新`); // 使用异步调度避免阻塞事件处理 setTimeout(() => { mutateProfiles().catch((error) => { console.error("[Profile] 配置数据刷新失败:", error); }); }, 0); }); }; setupListener(); return () => { unlistenPromise?.then((unlisten) => unlisten()).catch(console.error); }; }, [mutateProfiles]); // 组件卸载时清理中断控制器 useEffect(() => { return () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); debugProfileSwitch("COMPONENT_UNMOUNT_CLEANUP", "all"); } }; }, []); return ( {!batchMode ? ( <> {/* Batch mode toggle button */} configRef.current?.open()} > onEnhance(true)} > {/* 故障检测和紧急恢复按钮 */} {(error || isStale) && ( )} ) : ( // Batch mode header {getSelectionState() === "all" ? ( ) : getSelectionState() === "partial" ? ( ) : ( )} {t("Selected")} {selectedProfiles.size} {t("items")} )} } > setUrl(e.target.value)} onKeyDown={(event) => { if (event.key !== "Enter" || event.nativeEvent.isComposing) { return; } if (!url || disabled || loading) { return; } event.preventDefault(); void onImport(); }} placeholder={t("Profile URL")} slotProps={{ input: { 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 onEnhance(false); // await restartCore(); // Notice.success(t("Clash Core Restarted"), 1000); } }} onDelete={() => { if (batchMode) { toggleProfileSelection(item.uid); } else { onDelete(item.uid); } }} batchMode={batchMode} isSelected={selectedProfiles.has(item.uid)} onSelectionChange={() => toggleProfileSelection(item.uid)} /> ))} { if (prev !== curr) { await onEnhance(false); } }} /> { if (prev !== curr) { await onEnhance(false); } }} /> { mutateProfiles(); // 只有更改当前激活的配置时才触发全局重新加载 if (isActivating) { await onEnhance(false); } }} /> ); }; export default ProfilePage;