mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
29e87487a1
Closes #2139 Two related defects let the installed-skills count balloon when users tap the import confirm button multiple times — either deliberately or because the button is still clickable while a slow import is in flight: - The confirm button only disabled itself while `selected.size === 0`, so it stayed clickable during a pending mutation. Each extra click triggered another `importFromApps` mutation. - `useImportSkillsFromApps` appended the server response to the installed cache without deduping, so re-firing the mutation stacked the same skills into the list again. Disable the confirm (and cancel) buttons while the mutation is pending — matching the `isRestoring` / `isDeleting` pattern already used by `RestoreSkillsDialog` — and merge success payloads by `InstalledSkill.id` so repeated results overwrite rather than accumulate. The merge is extracted as a pure `mergeImportedSkills` reducer to make the behaviour unit-testable and to short-circuit on an empty payload, returning the existing reference so React Query does not notify subscribers about a no-op cache update.
359 lines
9.2 KiB
TypeScript
359 lines
9.2 KiB
TypeScript
import {
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
keepPreviousData,
|
|
} from "@tanstack/react-query";
|
|
import {
|
|
skillsApi,
|
|
type SkillBackupEntry,
|
|
type DiscoverableSkill,
|
|
type ImportSkillSelection,
|
|
type InstalledSkill,
|
|
type SkillUpdateInfo,
|
|
type SkillsShSearchResult,
|
|
} from "@/lib/api/skills";
|
|
import type { AppId } from "@/lib/api/types";
|
|
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
|
|
|
|
/**
|
|
* 查询所有已安装的 Skills
|
|
* 使用 staleTime: Infinity 和 placeholderData: keepPreviousData
|
|
* 实现首次进入使用缓存,只有刷新时才重新获取
|
|
*/
|
|
export function useInstalledSkills() {
|
|
return useQuery({
|
|
queryKey: ["skills", "installed"],
|
|
queryFn: () => skillsApi.getInstalled(),
|
|
staleTime: Infinity,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
export function useSkillBackups() {
|
|
return useQuery({
|
|
queryKey: ["skills", "backups"],
|
|
queryFn: () => skillsApi.getBackups(),
|
|
enabled: false,
|
|
});
|
|
}
|
|
|
|
export function useDeleteSkillBackup() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (backupId: string) => skillsApi.deleteBackup(backupId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 发现可安装的 Skills(从仓库获取)
|
|
* 使用 staleTime: Infinity 和 placeholderData: keepPreviousData
|
|
* 实现首次进入使用缓存,只有刷新时才重新获取
|
|
*/
|
|
export function useDiscoverableSkills() {
|
|
return useQuery({
|
|
queryKey: ["skills", "discoverable"],
|
|
queryFn: () => skillsApi.discoverAvailable(),
|
|
staleTime: Infinity,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 安装 Skill
|
|
* 成功后直接更新缓存,不触发重新加载/刷新
|
|
*/
|
|
export function useInstallSkill() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
skill,
|
|
currentApp,
|
|
}: {
|
|
skill: DiscoverableSkill;
|
|
currentApp: AppId;
|
|
}) => skillsApi.installUnified(skill, currentApp),
|
|
onSuccess: (installedSkill, _vars, _ctx) => {
|
|
const { skill } = _vars;
|
|
// 直接更新 installed 缓存
|
|
queryClient.setQueryData<InstalledSkill[]>(
|
|
["skills", "installed"],
|
|
(oldData) => {
|
|
if (!oldData) return [installedSkill];
|
|
return [...oldData, installedSkill];
|
|
},
|
|
);
|
|
|
|
// 更新 discoverable 缓存中对应技能的 installed 状态
|
|
const installName =
|
|
skill.directory.split(/[/\\]/).pop()?.toLowerCase() ||
|
|
skill.directory.toLowerCase();
|
|
const skillKey = `${installName}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`;
|
|
|
|
queryClient.setQueryData<DiscoverableSkill[]>(
|
|
["skills", "discoverable"],
|
|
(oldData) => {
|
|
if (!oldData) return oldData;
|
|
return oldData.map((s) => {
|
|
if (s.key === skillKey) {
|
|
return { ...s, installed: true };
|
|
}
|
|
return s;
|
|
});
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 卸载 Skill
|
|
* 成功后直接更新缓存,不触发重新加载/刷新
|
|
*/
|
|
export function useUninstallSkill() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, skillKey }: { id: string; skillKey: string }) =>
|
|
skillsApi
|
|
.uninstallUnified(id)
|
|
.then((result) => ({ ...result, skillKey })),
|
|
onSuccess: ({ skillKey }, _vars) => {
|
|
// 直接更新 installed 缓存,移除该 skill
|
|
queryClient.setQueryData<InstalledSkill[]>(
|
|
["skills", "installed"],
|
|
(oldData) => {
|
|
if (!oldData) return oldData;
|
|
return oldData.filter((s) => s.id !== _vars.id);
|
|
},
|
|
);
|
|
|
|
// 更新 discoverable 缓存中对应技能的 installed 状态
|
|
queryClient.setQueryData<DiscoverableSkill[]>(
|
|
["skills", "discoverable"],
|
|
(oldData) => {
|
|
if (!oldData) return oldData;
|
|
return oldData.map((s) => {
|
|
if (s.key === skillKey) {
|
|
return { ...s, installed: false };
|
|
}
|
|
return s;
|
|
});
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRestoreSkillBackup() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
backupId,
|
|
currentApp,
|
|
}: {
|
|
backupId: string;
|
|
currentApp: AppId;
|
|
}) => skillsApi.restoreBackup(backupId, currentApp),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 切换 Skill 在特定应用的启用状态
|
|
*/
|
|
export function useToggleSkillApp() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
id,
|
|
app,
|
|
enabled,
|
|
}: {
|
|
id: string;
|
|
app: AppId;
|
|
enabled: boolean;
|
|
}) => skillsApi.toggleApp(id, app, enabled),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 扫描未管理的 Skills
|
|
*/
|
|
export function useScanUnmanagedSkills() {
|
|
return useQuery({
|
|
queryKey: ["skills", "unmanaged"],
|
|
queryFn: () => skillsApi.scanUnmanaged(),
|
|
enabled: false, // 手动触发
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 从应用目录导入 Skills
|
|
* 成功后直接更新缓存,不触发重新加载/刷新
|
|
*/
|
|
export function useImportSkillsFromApps() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (imports: ImportSkillSelection[]) =>
|
|
skillsApi.importFromApps(imports),
|
|
onSuccess: (importedSkills) => {
|
|
// 直接更新 installed 缓存
|
|
queryClient.setQueryData<InstalledSkill[]>(
|
|
["skills", "installed"],
|
|
(oldData) => mergeImportedSkills(oldData, importedSkills),
|
|
);
|
|
// 刷新 unmanaged 列表(已被导入的应该移除)
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 获取仓库列表
|
|
*/
|
|
export function useSkillRepos() {
|
|
return useQuery({
|
|
queryKey: ["skills", "repos"],
|
|
queryFn: () => skillsApi.getRepos(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 添加仓库
|
|
*/
|
|
export function useAddSkillRepo() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: skillsApi.addRepo,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 删除仓库
|
|
*/
|
|
export function useRemoveSkillRepo() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ owner, name }: { owner: string; name: string }) =>
|
|
skillsApi.removeRepo(owner, name),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
|
|
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 从 ZIP 文件安装 Skills
|
|
* 成功后直接更新缓存,不触发重新加载/刷新
|
|
*/
|
|
export function useInstallSkillsFromZip() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
filePath,
|
|
currentApp,
|
|
}: {
|
|
filePath: string;
|
|
currentApp: AppId;
|
|
}) => skillsApi.installFromZip(filePath, currentApp),
|
|
onSuccess: (installedSkills) => {
|
|
// 直接更新 installed 缓存
|
|
queryClient.setQueryData<InstalledSkill[]>(
|
|
["skills", "installed"],
|
|
(oldData) => {
|
|
if (!oldData) return installedSkills;
|
|
return [...oldData, ...installedSkills];
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
// ========== 更新检测 ==========
|
|
|
|
/**
|
|
* 检查 Skills 更新(手动触发)
|
|
*/
|
|
export function useCheckSkillUpdates() {
|
|
return useQuery({
|
|
queryKey: ["skills", "updates"],
|
|
queryFn: () => skillsApi.checkUpdates(),
|
|
enabled: false,
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 更新单个 Skill
|
|
*/
|
|
export function useUpdateSkill() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) => skillsApi.updateSkill(id),
|
|
onSuccess: (updatedSkill) => {
|
|
queryClient.setQueryData<InstalledSkill[]>(
|
|
["skills", "installed"],
|
|
(oldData) => {
|
|
if (!oldData) return [updatedSkill];
|
|
return oldData.map((s) =>
|
|
s.id === updatedSkill.id ? updatedSkill : s,
|
|
);
|
|
},
|
|
);
|
|
queryClient.setQueryData<SkillUpdateInfo[]>(
|
|
["skills", "updates"],
|
|
(oldData) => {
|
|
if (!oldData) return oldData;
|
|
return oldData.filter((u) => u.id !== updatedSkill.id);
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
// ========== skills.sh 搜索 ==========
|
|
|
|
/**
|
|
* 搜索 skills.sh 公共目录
|
|
* 使用 300ms staleTime 和 keepPreviousData 实现平滑搜索体验
|
|
*/
|
|
export function useSearchSkillsSh(
|
|
query: string,
|
|
limit: number,
|
|
offset: number,
|
|
) {
|
|
return useQuery({
|
|
queryKey: ["skills", "skillssh", query, limit, offset],
|
|
queryFn: () => skillsApi.searchSkillsSh(query, limit, offset),
|
|
enabled: query.length >= 2,
|
|
staleTime: 5 * 60 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
// ========== 辅助类型 ==========
|
|
|
|
export type {
|
|
InstalledSkill,
|
|
DiscoverableSkill,
|
|
ImportSkillSelection,
|
|
SkillBackupEntry,
|
|
SkillUpdateInfo,
|
|
SkillsShSearchResult,
|
|
AppId,
|
|
};
|