Files
CC-Switch/src/components/skills/SkillsPage.tsx
T
YoVinchen 22552f7a7f fix(skills): prevent duplicate skill installation from different repos
- Add directory conflict detection before installation
- Fix installed status check to match repo owner and name
- Add i18n translations for conflict error messages
2026-01-25 06:10:24 +08:00

330 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Search } from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import {
useDiscoverableSkills,
useInstalledSkills,
useInstallSkill,
useSkillRepos,
useAddSkillRepo,
useRemoveSkillRepo,
type AppType,
} from "@/hooks/useSkills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
initialApp?: AppType;
}
export interface SkillsPageHandle {
refresh: () => void;
openRepoManager: () => void;
}
/**
* Skills 发现面板
* 用于浏览和安装来自仓库的 Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
const { t } = useTranslation();
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState<
"all" | "installed" | "uninstalled"
>("all");
// currentApp 用于安装时的默认应用
const currentApp = initialApp;
// Queries
const {
data: discoverableSkills,
isLoading: loadingDiscoverable,
isFetching: fetchingDiscoverable,
refetch: refetchDiscoverable,
} = useDiscoverableSkills();
const { data: installedSkills } = useInstalledSkills();
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
// Mutations
const installMutation = useInstallSkill();
const addRepoMutation = useAddSkillRepo();
const removeRepoMutation = useRemoveSkillRepo();
// 已安装的 skill key 集合(使用 directory + repoOwner + repoName 组合判断)
const installedKeys = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(
installedSkills.map((s) => {
// 构建唯一 keydirectory + repoOwner + repoName
const owner = s.repoOwner?.toLowerCase() || "";
const name = s.repoName?.toLowerCase() || "";
return `${s.directory.toLowerCase()}:${owner}:${name}`;
}),
);
}, [installedSkills]);
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
// 为发现列表补齐 installed 状态,供 SkillCard 使用
const skills: DiscoverableSkillItem[] = useMemo(() => {
if (!discoverableSkills) return [];
return discoverableSkills.map((d) => {
const installName =
d.directory.split("/").pop()?.toLowerCase() ||
d.directory.toLowerCase();
// 使用 directory + repoOwner + repoName 组合判断是否已安装
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
return {
...d,
installed: installedKeys.has(key),
};
});
}, [discoverableSkills, installedKeys]);
const loading = loadingDiscoverable || fetchingDiscoverable;
useImperativeHandle(ref, () => ({
refresh: () => {
refetchDiscoverable();
refetchRepos();
},
openRepoManager: () => setRepoManagerOpen(true),
}));
const handleInstall = async (directory: string) => {
// 找到对应的 DiscoverableSkill
const skill = discoverableSkills?.find(
(s) =>
s.directory === directory ||
s.directory.split("/").pop() === directory,
);
if (!skill) {
toast.error(t("skills.notFound"));
return;
}
try {
await installMutation.mutateAsync({
skill,
currentApp,
});
toast.success(t("skills.installSuccess", { name: skill.name }), {
closeButton: true,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.installFailed",
);
toast.error(title, {
description,
duration: 10000,
});
console.error("Install skill failed:", error);
}
};
const handleUninstall = async (_directory: string) => {
// 在发现面板中,不支持卸载,需要在主面板中操作
toast.info(t("skills.uninstallInMainPanel"));
};
const handleAddRepo = async (repo: SkillRepo) => {
try {
await addRepoMutation.mutateAsync(repo);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleRemoveRepo = async (owner: string, name: string) => {
try {
await removeRepoMutation.mutateAsync({ owner, name });
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
// 过滤技能列表
const filteredSkills = useMemo(() => {
const byStatus = skills.filter((skill) => {
if (filterStatus === "installed") return skill.installed;
if (filterStatus === "uninstalled") return !skill.installed;
return true;
});
if (!searchQuery.trim()) return byStatus;
const query = searchQuery.toLowerCase();
return byStatus.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
const description = skill.description?.toLowerCase() || "";
const directory = skill.directory?.toLowerCase() || "";
return (
name.includes(query) ||
description.includes(query) ||
directory.includes(query)
);
});
}, [skills, searchQuery, filterStatus]);
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : (
<>
{/* 搜索框 */}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("skills.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-3"
/>
</div>
<div className="w-full md:w-48">
<Select
value={filterStatus}
onValueChange={(val) =>
setFilterStatus(
val as "all" | "installed" | "uninstalled",
)
}
>
<SelectTrigger className="bg-card border shadow-sm text-foreground">
<SelectValue
placeholder={t("skills.filter.placeholder")}
className="text-left"
/>
</SelectTrigger>
<SelectContent className="bg-card text-foreground shadow-lg">
<SelectItem
value="all"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.all")}
</SelectItem>
<SelectItem
value="installed"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.installed")}
</SelectItem>
<SelectItem
value="uninstalled"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.uninstalled")}
</SelectItem>
</SelectContent>
</Select>
</div>
{searchQuery && (
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.count", { count: filteredSkills.length })}
</p>
)}
</div>
{/* 技能列表或无结果提示 */}
{filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)}
</>
)}
</div>
</div>
{/* 仓库管理面板 */}
{repoManagerOpen && (
<RepoManagerPanel
repos={repos}
skills={skills}
onAdd={handleAddRepo}
onRemove={handleRemoveRepo}
onClose={() => setRepoManagerOpen(false)}
/>
)}
</div>
);
},
);
SkillsPage.displayName = "SkillsPage";