mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 10:21:16 +08:00
feat(skills): add restore and delete for skill backups
Introduce list/restore/delete commands for skill backups created during uninstall. Restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure. Delete removes the backup directory after a confirmation dialog. ConfirmDialog gains a configurable zIndex prop to support nested dialog stacking.
This commit is contained in:
+11
@@ -1034,6 +1034,17 @@ function App() {
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openRestoreFromBackup()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
{t("skills.restoreFromBackup.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ConfirmDialogProps {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "destructive" | "info";
|
||||
zIndex?: "base" | "nested" | "alert" | "top";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export function ConfirmDialog({
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = "destructive",
|
||||
zIndex = "alert",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
@@ -46,7 +48,7 @@ export function ConfirmDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm" zIndex="alert">
|
||||
<DialogContent className="max-w-sm" zIndex={zIndex}>
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<IconComponent className={iconClass} />
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type ImportSkillSelection,
|
||||
type SkillBackupEntry,
|
||||
useDeleteSkillBackup,
|
||||
useInstalledSkills,
|
||||
useSkillBackups,
|
||||
useRestoreSkillBackup,
|
||||
useToggleSkillApp,
|
||||
useUninstallSkill,
|
||||
useScanUnmanagedSkills,
|
||||
@@ -21,6 +25,14 @@ import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
@@ -31,6 +43,14 @@ export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
openInstallFromZip: () => void;
|
||||
openRestoreFromBackup: () => void;
|
||||
}
|
||||
|
||||
function formatSkillBackupDate(unixSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 1000);
|
||||
return Number.isNaN(date.getTime())
|
||||
? String(unixSeconds)
|
||||
: date.toLocaleString();
|
||||
}
|
||||
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
@@ -42,13 +62,23 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
variant?: "destructive" | "info";
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const {
|
||||
data: skillBackups = [],
|
||||
refetch: refetchSkillBackups,
|
||||
isFetching: isFetchingSkillBackups,
|
||||
} = useSkillBackups();
|
||||
const deleteBackupMutation = useDeleteSkillBackup();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
const restoreBackupMutation = useRestoreSkillBackup();
|
||||
const { data: unmanagedSkills, refetch: scanUnmanaged } =
|
||||
useScanUnmanagedSkills();
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
@@ -152,10 +182,71 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRestoreFromBackup = async () => {
|
||||
setRestoreDialogOpen(true);
|
||||
try {
|
||||
await refetchSkillBackups();
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreFromBackup = async (backupId: string) => {
|
||||
try {
|
||||
const restored = await restoreBackupMutation.mutateAsync({
|
||||
backupId,
|
||||
currentApp,
|
||||
});
|
||||
setRestoreDialogOpen(false);
|
||||
toast.success(
|
||||
t("skills.restoreFromBackup.success", { name: restored.name }),
|
||||
{
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(t("skills.restoreFromBackup.failed"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBackup = (backup: SkillBackupEntry) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("skills.restoreFromBackup.deleteConfirmTitle"),
|
||||
message: t("skills.restoreFromBackup.deleteConfirmMessage", {
|
||||
name: backup.skill.name,
|
||||
}),
|
||||
confirmText: t("skills.restoreFromBackup.delete"),
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteBackupMutation.mutateAsync(backup.backupId);
|
||||
await refetchSkillBackups();
|
||||
setConfirmDialog(null);
|
||||
toast.success(
|
||||
t("skills.restoreFromBackup.deleteSuccess", {
|
||||
name: backup.skill.name,
|
||||
}),
|
||||
{
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(t("skills.restoreFromBackup.deleteFailed"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: onOpenDiscovery,
|
||||
openImport: handleOpenImport,
|
||||
openInstallFromZip: handleInstallFromZip,
|
||||
openRestoreFromBackup: handleOpenRestoreFromBackup,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -205,6 +296,9 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
confirmText={confirmDialog.confirmText}
|
||||
variant={confirmDialog.variant}
|
||||
zIndex="top"
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
@@ -217,6 +311,17 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RestoreSkillsDialog
|
||||
backups={skillBackups}
|
||||
isDeleting={deleteBackupMutation.isPending}
|
||||
isLoading={isFetchingSkillBackups}
|
||||
onDelete={handleDeleteBackup}
|
||||
isRestoring={restoreBackupMutation.isPending}
|
||||
onRestore={handleRestoreFromBackup}
|
||||
onClose={() => setRestoreDialogOpen(false)}
|
||||
open={restoreDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -318,6 +423,124 @@ interface ImportSkillsDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface RestoreSkillsDialogProps {
|
||||
backups: SkillBackupEntry[];
|
||||
isDeleting: boolean;
|
||||
isLoading: boolean;
|
||||
isRestoring: boolean;
|
||||
onDelete: (backup: SkillBackupEntry) => void;
|
||||
onRestore: (backupId: string) => void;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({
|
||||
backups,
|
||||
isDeleting,
|
||||
isLoading,
|
||||
isRestoring,
|
||||
onDelete,
|
||||
onRestore,
|
||||
onClose,
|
||||
open,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[85vh] flex flex-col"
|
||||
zIndex="alert"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("skills.restoreFromBackup.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("skills.restoreFromBackup.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("skills.restoreFromBackup.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.backupId}
|
||||
className="rounded-xl border border-border-default bg-background/70 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm text-foreground">
|
||||
{backup.skill.name}
|
||||
</div>
|
||||
<div className="rounded-md bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{backup.skill.directory}
|
||||
</div>
|
||||
</div>
|
||||
{backup.skill.description && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{backup.skill.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 space-y-1.5 text-xs text-muted-foreground">
|
||||
<div>
|
||||
{t("skills.restoreFromBackup.createdAt")}:{" "}
|
||||
{formatSkillBackupDate(backup.createdAt)}
|
||||
</div>
|
||||
<div className="break-all" title={backup.backupPath}>
|
||||
{t("skills.restoreFromBackup.path")}:{" "}
|
||||
{backup.backupPath}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:min-w-28">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onRestore(backup.backupId)}
|
||||
disabled={isRestoring || isDeleting}
|
||||
>
|
||||
{isRestoring
|
||||
? t("skills.restoreFromBackup.restoring")
|
||||
: t("skills.restoreFromBackup.restore")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(backup)}
|
||||
disabled={isRestoring || isDeleting}
|
||||
>
|
||||
{isDeleting
|
||||
? t("skills.restoreFromBackup.deleting")
|
||||
: t("skills.restoreFromBackup.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
skills,
|
||||
onImport,
|
||||
|
||||
+43
-1
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type SkillBackupEntry,
|
||||
type DiscoverableSkill,
|
||||
type ImportSkillSelection,
|
||||
type InstalledSkill,
|
||||
@@ -17,6 +18,24 @@ export function useInstalledSkills() {
|
||||
});
|
||||
}
|
||||
|
||||
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(从仓库获取)
|
||||
*/
|
||||
@@ -62,6 +81,23 @@ export function useUninstallSkill() {
|
||||
});
|
||||
}
|
||||
|
||||
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 在特定应用的启用状态
|
||||
*/
|
||||
@@ -170,4 +206,10 @@ export function useInstallSkillsFromZip() {
|
||||
|
||||
// ========== 辅助类型 ==========
|
||||
|
||||
export type { InstalledSkill, DiscoverableSkill, ImportSkillSelection, AppId };
|
||||
export type {
|
||||
InstalledSkill,
|
||||
DiscoverableSkill,
|
||||
ImportSkillSelection,
|
||||
SkillBackupEntry,
|
||||
AppId,
|
||||
};
|
||||
|
||||
@@ -1570,6 +1570,24 @@
|
||||
"backup": {
|
||||
"location": "Backup location: {{path}}"
|
||||
},
|
||||
"restoreFromBackup": {
|
||||
"button": "Restore Backup",
|
||||
"title": "Restore From Backup",
|
||||
"description": "Choose a Skills backup to restore its files locally and add it back to the current list.",
|
||||
"empty": "No Skills backups available to restore",
|
||||
"createdAt": "Backed up at",
|
||||
"path": "Backup path",
|
||||
"restore": "Restore",
|
||||
"restoring": "Restoring...",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"deleteSuccess": "Deleted backup for {{name}}",
|
||||
"deleteFailed": "Failed to delete skill backup",
|
||||
"deleteConfirmTitle": "Delete Backup",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete the backup for \"{{name}}\"? This action cannot be undone.",
|
||||
"success": "Skill {{name}} restored from backup",
|
||||
"failed": "Failed to restore from backup"
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
|
||||
@@ -1570,6 +1570,24 @@
|
||||
"backup": {
|
||||
"location": "バックアップ場所: {{path}}"
|
||||
},
|
||||
"restoreFromBackup": {
|
||||
"button": "バックアップから復元",
|
||||
"title": "バックアップから復元",
|
||||
"description": "Skills バックアップを選択して、ローカルにファイルを復元し、現在の一覧へ戻します。",
|
||||
"empty": "復元できる Skills バックアップはありません",
|
||||
"createdAt": "バックアップ日時",
|
||||
"path": "バックアップパス",
|
||||
"restore": "復元",
|
||||
"restoring": "復元中...",
|
||||
"delete": "削除",
|
||||
"deleting": "削除中...",
|
||||
"deleteSuccess": "スキルバックアップ {{name}} を削除しました",
|
||||
"deleteFailed": "スキルバックアップの削除に失敗しました",
|
||||
"deleteConfirmTitle": "バックアップを削除",
|
||||
"deleteConfirmMessage": "スキルバックアップ「{{name}}」を削除しますか?この操作は元に戻せません。",
|
||||
"success": "スキル {{name}} をバックアップから復元しました",
|
||||
"failed": "バックアップからの復元に失敗しました"
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
|
||||
@@ -1570,6 +1570,24 @@
|
||||
"backup": {
|
||||
"location": "备份位置: {{path}}"
|
||||
},
|
||||
"restoreFromBackup": {
|
||||
"button": "从备份中恢复",
|
||||
"title": "从备份中恢复",
|
||||
"description": "选择一个 Skills 备份,将文件恢复到本地并重新加入当前列表。",
|
||||
"empty": "暂无可恢复的 Skills 备份",
|
||||
"createdAt": "备份时间",
|
||||
"path": "备份路径",
|
||||
"restore": "恢复",
|
||||
"restoring": "恢复中...",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中...",
|
||||
"deleteSuccess": "技能备份 {{name}} 已删除",
|
||||
"deleteFailed": "删除技能备份失败",
|
||||
"deleteConfirmTitle": "确认删除备份",
|
||||
"deleteConfirmMessage": "确定要删除技能备份 \"{{name}}\" 吗?此操作无法撤销。",
|
||||
"success": "技能 {{name}} 已从备份恢复",
|
||||
"failed": "从备份恢复失败"
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
|
||||
@@ -31,6 +31,13 @@ export interface SkillUninstallResult {
|
||||
backupPath?: string;
|
||||
}
|
||||
|
||||
export interface SkillBackupEntry {
|
||||
backupId: string;
|
||||
backupPath: string;
|
||||
createdAt: number;
|
||||
skill: InstalledSkill;
|
||||
}
|
||||
|
||||
/** 可发现的 Skill(来自仓库) */
|
||||
export interface DiscoverableSkill {
|
||||
key: string;
|
||||
@@ -89,6 +96,16 @@ export const skillsApi = {
|
||||
return await invoke("get_installed_skills");
|
||||
},
|
||||
|
||||
/** 获取可恢复的 Skill 备份列表 */
|
||||
async getBackups(): Promise<SkillBackupEntry[]> {
|
||||
return await invoke("get_skill_backups");
|
||||
},
|
||||
|
||||
/** 删除 Skill 备份 */
|
||||
async deleteBackup(backupId: string): Promise<boolean> {
|
||||
return await invoke("delete_skill_backup", { backupId });
|
||||
},
|
||||
|
||||
/** 安装 Skill(统一安装) */
|
||||
async installUnified(
|
||||
skill: DiscoverableSkill,
|
||||
@@ -102,6 +119,14 @@ export const skillsApi = {
|
||||
return await invoke("uninstall_skill_unified", { id });
|
||||
},
|
||||
|
||||
/** 从备份恢复 Skill */
|
||||
async restoreBackup(
|
||||
backupId: string,
|
||||
currentApp: AppId,
|
||||
): Promise<InstalledSkill> {
|
||||
return await invoke("restore_skill_backup", { backupId, currentApp });
|
||||
},
|
||||
|
||||
/** 切换 Skill 的应用启用状态 */
|
||||
async toggleApp(id: string, app: AppId, enabled: boolean): Promise<boolean> {
|
||||
return await invoke("toggle_skill_app", { id, app, enabled });
|
||||
|
||||
Reference in New Issue
Block a user