From c31ab069831246f8ef2fbdc9fe201db5b090535a Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:04:31 +0800 Subject: [PATCH] refactor(profiles): route profile operations through the mutate funnel Ports profile create/import/reorder/delete/update/patch/save-file and enhance calls onto the funnel; retry and editor flows keep their own error handling with errorNotice:false. Per-uid ids keep the update worker pool concurrent under the funnel's in-flight dedupe. --- src/components/home/home-profile-card.tsx | 6 +- .../profile/groups-editor-viewer.tsx | 7 +- src/components/profile/profile-item.tsx | 32 +++++++-- src/components/profile/profile-more.tsx | 12 +++- src/components/profile/profile-viewer.tsx | 42 ++++++++++-- .../profile/proxies-editor-viewer.tsx | 7 +- .../profile/rules-editor-viewer.tsx | 7 +- src/components/setting/mods/tun-viewer.tsx | 6 +- src/pages/profiles.tsx | 67 ++++++++++++++----- 9 files changed, 152 insertions(+), 34 deletions(-) diff --git a/src/components/home/home-profile-card.tsx b/src/components/home/home-profile-card.tsx index 5b196fb4c..ad9b4bb82 100644 --- a/src/components/home/home-profile-card.tsx +++ b/src/components/home/home-profile-card.tsx @@ -26,6 +26,7 @@ import { useNavigate } from 'react-router' import { useAppRefreshers } from '@/providers/app-data-context' import { updateProfile } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { isValidUrl } from '@/utils/network' import { openExternalUrl } from '@/utils/open-external-url' @@ -297,7 +298,10 @@ export const HomeProfileCard = ({ setUpdating(true) try { - await updateProfile(current.uid, current.option) + await mutate(() => updateProfile(current.uid, current.option), { + id: `update-profile:${current.uid}`, + errorNotice: false, + }) onProfileUpdated?.() // 刷新首页数据 diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx index 66fc3d836..ddacd7971 100644 --- a/src/components/profile/groups-editor-viewer.tsx +++ b/src/components/profile/groups-editor-viewer.tsx @@ -42,6 +42,7 @@ import { readProfileFile, saveProfileFile, } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { TranslationKey } from '@/types/generated/i18n-keys' @@ -472,7 +473,11 @@ export const GroupsEditorViewer = (props: Props) => { setCurrData(nextData) } - if (!(await saveProfileFile(property, nextData))) { + const result = await mutate(() => saveProfileFile(property, nextData), { + id: `save-profile-file:${property}`, + errorNotice: false, + }) + if (!(result.ok && result.value === true)) { await fetchContent() onClose() return diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 6685a81aa..ed3c430ca 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -39,6 +39,7 @@ import { updateProfile, viewProfile, } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useProfileLoadingCache, @@ -369,7 +370,10 @@ const ProfileItemBase = (props: ProfileItemProps) => { const onOpenFile = useLockFn(async () => { setAnchorEl(null) try { - await viewProfile(itemData.uid) + await mutate(() => viewProfile(itemData.uid), { + id: 'view-profile', + errorNotice: false, + }) } catch (err) { showNotice.error(err) } @@ -398,7 +402,10 @@ const ProfileItemBase = (props: ProfileItemProps) => { try { const payload = Object.keys(option).length > 0 ? option : undefined - await updateProfile(itemData.uid, payload) + await mutate(() => updateProfile(itemData.uid, payload), { + id: `update-profile:${itemData.uid}`, + errorNotice: false, + }) void mutateProfiles() } catch { @@ -586,7 +593,11 @@ const ProfileItemBase = (props: ProfileItemProps) => { const handleSaveProfileDocument = useLockFn(async () => { const currentValue = profileDocument.value - if (!(await saveProfileFile(uid, currentValue))) { + const result = await mutate(() => saveProfileFile(uid, currentValue), { + id: `save-profile-file:${uid}`, + errorNotice: false, + }) + if (!(result.ok && result.value === true)) { await profileDocument.reload() return } @@ -597,7 +608,11 @@ const ProfileItemBase = (props: ProfileItemProps) => { const handleSaveMergeDocument = useLockFn(async () => { const mergeUid = option?.merge ?? '' const currentValue = mergeDocument.value - if (!(await saveProfileFile(mergeUid, currentValue))) { + const result = await mutate(() => saveProfileFile(mergeUid, currentValue), { + id: `save-profile-file:${mergeUid}`, + errorNotice: false, + }) + if (!(result.ok && result.value === true)) { await mergeDocument.reload() return } @@ -608,7 +623,14 @@ const ProfileItemBase = (props: ProfileItemProps) => { const handleSaveScriptDocument = useLockFn(async () => { const scriptUid = option?.script ?? '' const currentValue = scriptDocument.value - if (!(await saveProfileFile(scriptUid, currentValue))) { + const result = await mutate( + () => saveProfileFile(scriptUid, currentValue), + { + id: `save-profile-file:${scriptUid}`, + errorNotice: false, + }, + ) + if (!(result.ok && result.value === true)) { await scriptDocument.reload() return } diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index dc5ce8799..eb4934067 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next' import { EditorViewer } from '@/components/profile/editor-viewer' import { useEditorDocument } from '@/hooks/use-editor-document' import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { EnhanceHint } from './enhance-hint' @@ -63,7 +64,10 @@ export const ProfileMore = (props: Props) => { const onOpenFile = useLockFn(async () => { setAnchorEl(null) try { - await viewProfile(id) + await mutate(() => viewProfile(id), { + id: 'view-profile', + errorNotice: false, + }) } catch (err) { showNotice.error(err) } @@ -96,7 +100,11 @@ export const ProfileMore = (props: Props) => { const handleSave = useLockFn(async () => { const currentValue = document.value - if (!(await saveProfileFile(id, currentValue))) { + const result = await mutate(() => saveProfileFile(id, currentValue), { + id: `save-profile-file:${id}`, + errorNotice: false, + }) + if (!(result.ok && result.value === true)) { await document.reload() return } diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 1a75e725f..e40b9f4d5 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next' import { BaseDialog, Switch } from '@/components/base' import { useProfiles } from '@/hooks/use-profiles' import { createProfile, patchProfile } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { version } from '@root/package.json' @@ -128,26 +129,38 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { if (!isRemote) { if (openType === 'new') { - await createProfile(item, fileDataRef.current) + await mutate(() => createProfile(item, fileDataRef.current), { + id: 'create-profile', + errorNotice: false, + }) } else { if (!form.uid) { throw new Error( t('profiles.modals.profileForm.errors.uidMissing'), ) } - await patchProfile(form.uid, item) + await mutate(() => patchProfile(form.uid, item), { + id: 'patch-profile', + errorNotice: false, + }) } } else { try { if (openType === 'new') { - await createProfile(item, fileDataRef.current) + await mutate(() => createProfile(item, fileDataRef.current), { + id: 'create-profile', + errorNotice: false, + }) } else { if (!form.uid) { throw new Error( t('profiles.modals.profileForm.errors.uidMissing'), ) } - await patchProfile(form.uid, item) + await mutate(() => patchProfile(form.uid, item), { + id: 'patch-profile', + errorNotice: false, + }) } } catch { showNotice.info( @@ -164,16 +177,31 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } if (openType === 'new') { - await createProfile(retryItem, fileDataRef.current) + await mutate( + () => createProfile(retryItem, fileDataRef.current), + { + id: 'create-profile', + errorNotice: false, + }, + ) } else { if (!form.uid) { throw new Error( t('profiles.modals.profileForm.errors.uidMissing'), ) } - await patchProfile(form.uid, retryItem) + await mutate(() => patchProfile(form.uid, retryItem), { + id: 'patch-profile', + errorNotice: false, + }) - await patchProfile(form.uid, { option: originalOptions }) + await mutate( + () => patchProfile(form.uid, { option: originalOptions }), + { + id: 'patch-profile', + errorNotice: false, + }, + ) } showNotice.success( diff --git a/src/components/profile/proxies-editor-viewer.tsx b/src/components/profile/proxies-editor-viewer.tsx index 9d3666752..23fc0398e 100644 --- a/src/components/profile/proxies-editor-viewer.tsx +++ b/src/components/profile/proxies-editor-viewer.tsx @@ -30,6 +30,7 @@ import { useTranslation } from 'react-i18next' import { BaseSearchBox, MonacoEditor } from '@/components/base' import { ProxyItem } from '@/components/profile/proxy-item' import { readProfileFile, saveProfileFile } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { MonacoEditorInstance } from '@/types/monaco' @@ -329,7 +330,11 @@ export const ProxiesEditorViewer = (props: Props) => { const handleSave = useLockFn(async () => { try { - if (!(await saveProfileFile(property, currData))) { + const result = await mutate(() => saveProfileFile(property, currData), { + id: `save-profile-file:${property}`, + errorNotice: false, + }) + if (!(result.ok && result.value === true)) { await fetchContent() onClose() return diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index 0c4756474..540981203 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next' import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base' import { RuleItem } from '@/components/profile/rule-item' import { readProfileFile, saveProfileFile } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { TranslationKey } from '@/types/generated/i18n-keys' @@ -570,7 +571,11 @@ export const RulesEditorViewer = (props: Props) => { const handleSave = useLockFn(async () => { try { - if (!(await saveProfileFile(property, currData))) { + const result = await mutate(() => saveProfileFile(property, currData), { + id: `save-profile-file:${property}`, + errorNotice: false, + }) + if (!(result.ok && result.value === true)) { await fetchContent() onClose() return diff --git a/src/components/setting/mods/tun-viewer.tsx b/src/components/setting/mods/tun-viewer.tsx index 5fa9d3bd5..9abe92175 100644 --- a/src/components/setting/mods/tun-viewer.tsx +++ b/src/components/setting/mods/tun-viewer.tsx @@ -21,6 +21,7 @@ import { } from '@/components/base' import { useClash } from '@/hooks/use-clash' import { enhanceProfiles } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import getSystem from '@/utils/get-system' import { areValidIpCidrs } from '@/utils/network' @@ -129,7 +130,10 @@ export function TunViewer({ ref }: { ref?: Ref }) { ) setOpen(false) showNotice.success('settings.modals.tun.messages.applied') - void enhanceProfiles().catch((err: any) => { + void mutate(() => enhanceProfiles(), { + id: 'enhance-profiles', + errorNotice: false, + }).catch((err: any) => { showNotice.error(err) }) } catch (err: any) { diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 5b191a8a1..aee5dc484 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -49,6 +49,7 @@ import { reorderProfile, updateProfile, } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { errorDetail, showNotice } from '@/services/notice-service' import { revalidateQuery, useQuery } from '@/services/query-client' import { useThemeMode } from '@/services/states' @@ -140,16 +141,22 @@ const ProfilePage = () => { }, } as IProfileItem const data = await readTextFile(file) - await createProfile(item, data) - await mutateProfiles() + await mutate(() => createProfile(item, data), { + id: 'create-profile', + errorNotice: false, + revalidate: [['getProfiles']], + }) } - await enhanceProfiles() + await mutate(() => enhanceProfiles(), { + id: 'enhance-profiles', + errorNotice: false, + }) }) return () => { void unlisten.then((cleanup) => cleanup()) } - }, [mutateProfiles]) + }, []) const onEmergencyRefresh = useLockFn(async () => { debugLog('[紧急刷新] 开始强制刷新所有数据') @@ -211,7 +218,10 @@ const ProfilePage = () => { await performRobustRefresh() } try { - await importProfile(url) + await mutate(() => importProfile(url), { + id: 'import-profile', + errorNotice: false, + }) await handleImportSuccess('shared.feedback.notifications.importSuccess') } catch (initialErr) { console.warn('[订阅导入] 首次导入失败:', initialErr) @@ -224,10 +234,17 @@ const ProfilePage = () => { showNotice.info('profiles.page.feedback.notifications.importRetry') try { - await importProfile(url, { - with_proxy: false, - self_proxy: true, - }) + await mutate( + () => + importProfile(url, { + with_proxy: false, + self_proxy: true, + }), + { + id: 'import-profile', + errorNotice: false, + }, + ) await handleImportSuccess( 'shared.feedback.notifications.importWithClashProxy', ) @@ -308,7 +325,10 @@ const ProfilePage = () => { if (activeId == null || overId == null || activeId === overId) return try { - await reorderProfile(activeId, overId) + await mutate(() => reorderProfile(activeId, overId), { + id: 'reorder-profile', + errorNotice: false, + }) mutateProfiles() } catch (error) { setProfileDndRevision((revision) => revision + 1) @@ -339,7 +359,10 @@ const ProfilePage = () => { if (outcome.status === 'valid') { currentProfileRef.current = profile void mutateLogs().catch(() => {}) - void closeAllConnections().catch(() => {}) + void mutate(() => closeAllConnections(), { + id: 'close-all-connections', + errorNotice: false, + }).catch(() => {}) if ( notifySuccess && @@ -465,7 +488,11 @@ const ProfilePage = () => { setActivatings((prev) => [...new Set([...prev, ...currentProfiles])]) try { - if (!(await enhanceProfiles())) return + const result = await mutate(() => enhanceProfiles(), { + id: 'enhance-profiles', + errorNotice: false, + }) + if (!(result.ok && result.value === true)) return mutateLogs() if (notifySuccess) { showNotice.success( @@ -484,7 +511,10 @@ const ProfilePage = () => { const current = profiles.current === uid try { setActivatings([...(current ? currentActivatings() : []), uid]) - await deleteProfile(uid) + await mutate(() => deleteProfile(uid), { + id: 'delete-profile', + errorNotice: false, + }) mutateProfiles() mutateLogs() if (current) { @@ -519,7 +549,11 @@ const ProfilePage = () => { const updateOne = async (uid: string) => { try { - await updateProfile(uid) + // Per-uid id: the worker pool must not dedupe distinct subscriptions. + await mutate(() => updateProfile(uid), { + id: `update-profile:${uid}`, + errorNotice: false, + }) throttleMutate() } catch (err: any) { console.error(`更新订阅 ${uid} 失败:`, err) @@ -613,7 +647,10 @@ const ProfilePage = () => { setActivatings((prev) => [...new Set([...prev, ...currentActivating])]) for (const uid of selectedProfiles) { - await deleteProfile(uid) + await mutate(() => deleteProfile(uid), { + id: 'delete-profile', + errorNotice: false, + }) } await mutateProfiles()