mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
f349d85e85
- Increase SelectContent z-index from z-50 to z-[100] to appear above FullScreenPanel (z-[60]) - Replace form.watch() with form.getValues() in useCallback handlers for correct react-hook-form usage - Remove max-w-[56rem] constraints from various panels for consistent full-width layout
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
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();
|
|
|
|
// 已安装的 directory 集合
|
|
const installedDirs = useMemo(() => {
|
|
if (!installedSkills) return new Set<string>();
|
|
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
|
|
}, [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();
|
|
return {
|
|
...d,
|
|
installed: installedDirs.has(installName),
|
|
};
|
|
});
|
|
}, [discoverableSkills, installedDirs]);
|
|
|
|
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";
|