mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
Merge origin/main into main
Resolved conflict in src-tauri/src/commands/misc.rs by combining imports from both sides. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+102
-34
@@ -13,6 +13,8 @@ import {
|
||||
Wrench,
|
||||
Server,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import type { Provider } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||
@@ -41,6 +44,7 @@ import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
@@ -51,6 +55,7 @@ type View =
|
||||
| "settings"
|
||||
| "prompts"
|
||||
| "skills"
|
||||
| "skillsDiscovery"
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal";
|
||||
@@ -73,25 +78,14 @@ function App() {
|
||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||
|
||||
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
||||
const lastUsageProviderRef = useRef<Provider | null>(null);
|
||||
const lastEditingProviderRef = useRef<Provider | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (usageProvider) {
|
||||
lastUsageProviderRef.current = usageProvider;
|
||||
}
|
||||
}, [usageProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProvider) {
|
||||
lastEditingProviderRef.current = editingProvider;
|
||||
}
|
||||
}, [editingProvider]);
|
||||
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
|
||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
const unifiedSkillsPanelRef = useRef<any>(null);
|
||||
const addActionButtonClass =
|
||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||
|
||||
@@ -117,8 +111,7 @@ function App() {
|
||||
});
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
// Skills 功能仅支持 Claude 和 Codex
|
||||
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
|
||||
const hasSkillsSupport = true;
|
||||
|
||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||
const {
|
||||
@@ -229,6 +222,35 @@ function App() {
|
||||
checkMigration();
|
||||
}, [t]);
|
||||
|
||||
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
|
||||
useEffect(() => {
|
||||
const checkSkillsMigration = async () => {
|
||||
try {
|
||||
const result = await invoke<{ count: number; error?: string } | null>(
|
||||
"get_skills_migration_result",
|
||||
);
|
||||
if (result?.error) {
|
||||
toast.error(t("migration.skillsFailed"), {
|
||||
description: t("migration.skillsFailedDescription"),
|
||||
closeButton: true,
|
||||
});
|
||||
console.error("[App] Skills SSOT migration failed:", result.error);
|
||||
return;
|
||||
}
|
||||
if (result && result.count > 0) {
|
||||
toast.success(t("migration.skillsSuccess", { count: result.count }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to check skills migration result:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkSkillsMigration();
|
||||
}, [t, queryClient]);
|
||||
|
||||
// 切换应用时检测当前应用的环境变量冲突
|
||||
useEffect(() => {
|
||||
const checkEnvOnSwitch = async () => {
|
||||
@@ -421,10 +443,16 @@ function App() {
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return (
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
/>
|
||||
);
|
||||
case "skillsDiscovery":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
onClose={() => setCurrentView("providers")}
|
||||
initialApp={activeApp}
|
||||
/>
|
||||
);
|
||||
@@ -564,7 +592,11 @@ function App() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentView("providers")}
|
||||
onClick={() =>
|
||||
setCurrentView(
|
||||
currentView === "skillsDiscovery" ? "skills" : "providers",
|
||||
)
|
||||
}
|
||||
className="mr-2 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
@@ -574,6 +606,7 @@ function App() {
|
||||
{currentView === "prompts" &&
|
||||
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "skillsDiscovery" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && t("agents.title")}
|
||||
{currentView === "universal" &&
|
||||
@@ -619,25 +652,60 @@ function App() {
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className={`ml-auto ${addActionButtonClass}`}
|
||||
title={t("prompts.add")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className={`ml-auto ${addActionButtonClass}`}
|
||||
title={t("mcp.unifiedPanel.addServer")}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("mcp.addMcp")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -739,7 +807,7 @@ function App() {
|
||||
|
||||
<EditProviderDialog
|
||||
open={Boolean(editingProvider)}
|
||||
provider={lastEditingProviderRef.current}
|
||||
provider={effectiveEditingProvider}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingProvider(null);
|
||||
@@ -750,9 +818,9 @@ function App() {
|
||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||
/>
|
||||
|
||||
{lastUsageProviderRef.current && (
|
||||
{effectiveUsageProvider && (
|
||||
<UsageScriptModal
|
||||
provider={lastUsageProviderRef.current}
|
||||
provider={effectiveUsageProvider}
|
||||
appId={activeApp}
|
||||
isOpen={Boolean(usageProvider)}
|
||||
onClose={() => setUsageProvider(null)}
|
||||
|
||||
@@ -12,6 +12,9 @@ interface FullScreenPanelProps {
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
|
||||
const HEADER_HEIGHT = 64; // px - match App.tsx
|
||||
|
||||
/**
|
||||
* Reusable full-screen panel component
|
||||
* Handles portal rendering, header with back button, and footer
|
||||
@@ -44,18 +47,30 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
className="fixed inset-0 z-[60] flex flex-col"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
>
|
||||
{/* Header */}
|
||||
{/* Drag region - match App.tsx */}
|
||||
<div
|
||||
className="flex-shrink-0 py-3 border-b border-border-default"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
WebkitAppRegion: "drag",
|
||||
height: DRAG_BAR_HEIGHT,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* Header - match App.tsx */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
height: HEADER_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<div className="h-4 w-full" data-tauri-drag-region />
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
||||
<div className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -3,12 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
|
||||
import { useAllMcpServers, useToggleMcpApp, useDeleteMcpServer, useImportMcpFromApps } from "@/hooks/useMcp";
|
||||
import type { McpServer } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import McpFormModal from "./McpFormModal";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { useDeleteMcpServer } from "@/hooks/useMcp";
|
||||
import { Edit3, Trash2 } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
@@ -24,6 +23,7 @@ interface UnifiedMcpPanelProps {
|
||||
*/
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
openAdd: () => void;
|
||||
openImport: () => void;
|
||||
}
|
||||
|
||||
const UnifiedMcpPanel = React.forwardRef<
|
||||
@@ -44,6 +44,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||
const toggleAppMutation = useToggleMcpApp();
|
||||
const deleteServerMutation = useDeleteMcpServer();
|
||||
const importMutation = useImportMcpFromApps();
|
||||
|
||||
// Convert serversMap to array for easier rendering
|
||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||
@@ -86,8 +87,24 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const count = await importMutation.mutateAsync();
|
||||
if (count === 0) {
|
||||
toast.success(t("mcp.unifiedPanel.noImportFound"), { closeButton: true });
|
||||
} else {
|
||||
toast.success(t("mcp.unifiedPanel.importSuccess", { count }), { closeButton: true });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openAdd: handleAdd,
|
||||
openImport: handleImport,
|
||||
}));
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface PromptToggleProps {
|
||||
|
||||
/**
|
||||
* Toggle 开关组件(提示词专用)
|
||||
* 启用时为蓝色,禁用时为灰色
|
||||
* 启用时为绿色,禁用时为灰色
|
||||
*/
|
||||
const PromptToggle: React.FC<PromptToggleProps> = ({
|
||||
enabled,
|
||||
@@ -23,8 +23,8 @@ const PromptToggle: React.FC<PromptToggleProps> = ({
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
||||
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
|
||||
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -222,9 +222,7 @@ export function ClaudeFormFields({
|
||||
{/* 推理模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="reasoningModel">
|
||||
{t("providerForm.anthropicReasoningModel", {
|
||||
defaultValue: "推理模型 (Thinking)",
|
||||
})}
|
||||
{t("providerForm.anthropicReasoningModel")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="reasoningModel"
|
||||
@@ -233,9 +231,6 @@ export function ClaudeFormFields({
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.reasoningModelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -142,12 +142,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData.maxRetries}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
maxRetries: parseInt(e.target.value) || 3,
|
||||
})
|
||||
}
|
||||
maxRetries: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -168,12 +169,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.circuitFailureThreshold}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitFailureThreshold: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -206,12 +208,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="180"
|
||||
value={formData.streamingFirstByteTimeout}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -232,12 +235,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="600"
|
||||
value={formData.streamingIdleTimeout}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingIdleTimeout: parseInt(e.target.value) || 60,
|
||||
})
|
||||
}
|
||||
streamingIdleTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -258,12 +262,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="1800"
|
||||
value={formData.nonStreamingTimeout}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
nonStreamingTimeout: parseInt(e.target.value) || 300,
|
||||
})
|
||||
}
|
||||
nonStreamingTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -293,12 +298,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.circuitSuccessThreshold}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitSuccessThreshold: parseInt(e.target.value) || 2,
|
||||
})
|
||||
}
|
||||
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -319,12 +325,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="10"
|
||||
max="300"
|
||||
value={formData.circuitTimeoutSeconds}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
|
||||
})
|
||||
}
|
||||
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -346,13 +353,13 @@ export function AutoFailoverConfigPanel({
|
||||
max="100"
|
||||
step="5"
|
||||
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitErrorRateThreshold:
|
||||
(parseInt(e.target.value) || 50) / 100,
|
||||
})
|
||||
}
|
||||
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -373,12 +380,13 @@ export function AutoFailoverConfigPanel({
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.circuitMinRequests}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitMinRequests: parseInt(e.target.value) || 10,
|
||||
})
|
||||
}
|
||||
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
|
||||
});
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -312,7 +312,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3 px-1">
|
||||
{["claude", "codex", "gemini"].map((toolName, index) => {
|
||||
const tool = toolVersions.find((item) => item.name === toolName);
|
||||
const displayName = tool?.name ?? toolName;
|
||||
|
||||
@@ -12,13 +12,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
repos: SkillRepo[];
|
||||
skills: Skill[];
|
||||
skills: DiscoverableSkill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerPanelProps {
|
||||
repos: SkillRepo[];
|
||||
skills: Skill[];
|
||||
skills: DiscoverableSkill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
@@ -92,7 +92,7 @@ export function RepoManagerPanel({
|
||||
{/* 添加仓库表单 */}
|
||||
<div className="space-y-4 glass-card rounded-xl p-6">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
添加技能仓库
|
||||
{t("skills.addRepo")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -12,10 +12,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { DiscoverableSkill } from "@/lib/api/skills";
|
||||
|
||||
type SkillCardSkill = DiscoverableSkill & { installed: boolean };
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: Skill;
|
||||
skill: SkillCardSkill;
|
||||
onInstall: (directory: string) => Promise<void>;
|
||||
onUninstall: (directory: string) => Promise<void>;
|
||||
}
|
||||
@@ -57,7 +59,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
||||
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
|
||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:shadow-lg group relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -20,15 +14,18 @@ import { toast } from "sonner";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
import {
|
||||
skillsApi,
|
||||
type Skill,
|
||||
type SkillRepo,
|
||||
useDiscoverableSkills,
|
||||
useInstalledSkills,
|
||||
useInstallSkill,
|
||||
useSkillRepos,
|
||||
useAddSkillRepo,
|
||||
useRemoveSkillRepo,
|
||||
type AppType,
|
||||
} from "@/lib/api/skills";
|
||||
} from "@/hooks/useSkills";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
interface SkillsPageProps {
|
||||
onClose?: () => void;
|
||||
initialApp?: AppType;
|
||||
}
|
||||
|
||||
@@ -37,163 +34,138 @@ export interface SkillsPageHandle {
|
||||
openRepoManager: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills 发现面板
|
||||
* 用于浏览和安装来自仓库的 Skills
|
||||
*/
|
||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
({ onClose: _onClose, initialApp = "claude" }, ref) => {
|
||||
({ initialApp = "claude" }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
"all" | "installed" | "uninstalled"
|
||||
>("all");
|
||||
// 使用 initialApp,不允许切换
|
||||
const selectedApp = initialApp;
|
||||
|
||||
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await skillsApi.getAll(selectedApp);
|
||||
setSkills(data);
|
||||
if (afterLoad) {
|
||||
afterLoad(data);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
// currentApp 用于安装时的默认应用
|
||||
const currentApp = initialApp;
|
||||
|
||||
// 传入 "skills.loadFailed" 作为标题
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.loadFailed",
|
||||
);
|
||||
// Queries
|
||||
const {
|
||||
data: discoverableSkills,
|
||||
isLoading: loadingDiscoverable,
|
||||
isFetching: fetchingDiscoverable,
|
||||
refetch: refetchDiscoverable,
|
||||
} = useDiscoverableSkills();
|
||||
const { data: installedSkills } = useInstalledSkills();
|
||||
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 8000,
|
||||
});
|
||||
// Mutations
|
||||
const installMutation = useInstallSkill();
|
||||
const addRepoMutation = useAddSkillRepo();
|
||||
const removeRepoMutation = useRemoveSkillRepo();
|
||||
|
||||
console.error("Load skills failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// 已安装的 directory 集合
|
||||
const installedDirs = useMemo(() => {
|
||||
if (!installedSkills) return new Set<string>();
|
||||
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
|
||||
}, [installedSkills]);
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await skillsApi.getRepos();
|
||||
setRepos(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load repos:", error);
|
||||
}
|
||||
};
|
||||
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadSkills(), loadRepos()]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// 为发现列表补齐 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: () => loadSkills(),
|
||||
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 skillsApi.install(directory, selectedApp);
|
||||
toast.success(t("skills.installSuccess", { name: directory }), {
|
||||
await installMutation.mutateAsync({
|
||||
skill,
|
||||
currentApp,
|
||||
});
|
||||
toast.success(t("skills.installSuccess", { name: skill.name }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 使用错误解析器格式化错误,传入 "skills.installFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.installFailed",
|
||||
);
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000, // 延长显示时间让用户看清
|
||||
});
|
||||
|
||||
console.error("Install skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (directory: string) => {
|
||||
try {
|
||||
await skillsApi.uninstall(directory, selectedApp);
|
||||
toast.success(t("skills.uninstallSuccess", { name: directory }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await loadSkills();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
|
||||
const { title, description } = formatSkillError(
|
||||
errorMessage,
|
||||
t,
|
||||
"skills.uninstallFailed",
|
||||
);
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
duration: 10000,
|
||||
});
|
||||
console.error("Install skill failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
console.error("Uninstall skill failed:", {
|
||||
directory,
|
||||
error,
|
||||
message: errorMessage,
|
||||
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 handleAddRepo = async (repo: SkillRepo) => {
|
||||
await skillsApi.addRepo(repo);
|
||||
|
||||
let repoSkillCount = 0;
|
||||
await Promise.all([
|
||||
loadRepos(),
|
||||
loadSkills((data) => {
|
||||
repoSkillCount = data.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
}),
|
||||
]);
|
||||
|
||||
toast.success(
|
||||
t("skills.repo.addSuccess", {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
count: repoSkillCount,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||
await skillsApi.removeRepo(owner, name);
|
||||
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
|
||||
closeButton: true,
|
||||
});
|
||||
await Promise.all([loadRepos(), loadSkills()]);
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤技能列表
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useInstalledSkills,
|
||||
useToggleSkillApp,
|
||||
useUninstallSkill,
|
||||
useScanUnmanagedSkills,
|
||||
useImportSkillsFromApps,
|
||||
type InstalledSkill,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 Skills 管理面板
|
||||
* v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端
|
||||
*/
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
}
|
||||
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
UnifiedSkillsPanelHandle,
|
||||
UnifiedSkillsPanelProps
|
||||
>(({ onOpenDiscovery }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
|
||||
// Queries and Mutations
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
const { data: unmanagedSkills, refetch: scanUnmanaged } =
|
||||
useScanUnmanagedSkills();
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
|
||||
// Count enabled skills per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
if (skill.apps.claude) counts.claude++;
|
||||
if (skill.apps.codex) counts.codex++;
|
||||
if (skill.apps.gemini) counts.gemini++;
|
||||
});
|
||||
return counts;
|
||||
}, [skills]);
|
||||
|
||||
const handleToggleApp = async (
|
||||
id: string,
|
||||
app: AppType,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = (skill: InstalledSkill) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("skills.uninstall"),
|
||||
message: t("skills.uninstallConfirm", { name: skill.name }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await uninstallMutation.mutateAsync(skill.id);
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenImport = async () => {
|
||||
try {
|
||||
const result = await scanUnmanaged();
|
||||
if (!result.data || result.data.length === 0) {
|
||||
toast.success(t("skills.noUnmanagedFound"), { closeButton: true });
|
||||
return;
|
||||
}
|
||||
setImportDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (directories: string[]) => {
|
||||
try {
|
||||
const imported = await importMutation.mutateAsync(directories);
|
||||
setImportDialogOpen(false);
|
||||
toast.success(
|
||||
t("skills.importSuccess", { count: imported.length }),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: onOpenDiscovery,
|
||||
openImport: handleOpenImport,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
||||
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("skills.apps.gemini")}: {enabledCounts.gemini}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("skills.loading")}
|
||||
</div>
|
||||
) : !skills || skills.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||
<Sparkles size={24} className="text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("skills.noInstalled")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("skills.noInstalledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{skills.map((skill) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import Dialog */}
|
||||
{importDialogOpen && unmanagedSkills && (
|
||||
<ImportSkillsDialog
|
||||
skills={unmanagedSkills}
|
||||
onImport={handleImport}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
|
||||
|
||||
/**
|
||||
* 已安装 Skill 列表项组件
|
||||
*/
|
||||
interface InstalledSkillListItemProps {
|
||||
skill: InstalledSkill;
|
||||
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
skill,
|
||||
onToggleApp,
|
||||
onUninstall,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openDocs = async () => {
|
||||
if (!skill.readmeUrl) return;
|
||||
try {
|
||||
await settingsApi.openExternal(skill.readmeUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 生成来源标签
|
||||
const sourceLabel = useMemo(() => {
|
||||
if (skill.repoOwner && skill.repoName) {
|
||||
return `${skill.repoOwner}/${skill.repoName}`;
|
||||
}
|
||||
return t("skills.local");
|
||||
}, [skill.repoOwner, skill.repoName, t]);
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:Skill 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{skill.name}</h3>
|
||||
{skill.readmeUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-claude`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-claude`}
|
||||
checked={skill.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-codex`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-codex`}
|
||||
checked={skill.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-gemini`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-gemini`}
|
||||
checked={skill.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:删除按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onUninstall}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("skills.uninstall")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入 Skills 对话框
|
||||
*/
|
||||
interface ImportSkillsDialogProps {
|
||||
skills: Array<{
|
||||
directory: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
foundIn: string[];
|
||||
}>;
|
||||
onImport: (directories: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
skills,
|
||||
onImport,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(skills.map((s) => s.directory)),
|
||||
);
|
||||
|
||||
const toggleSelect = (directory: string) => {
|
||||
const newSelected = new Set(selected);
|
||||
if (newSelected.has(directory)) {
|
||||
newSelected.delete(directory);
|
||||
} else {
|
||||
newSelected.add(directory);
|
||||
}
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
onImport(Array.from(selected));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("skills.importDescription")}
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
|
||||
{skills.map((skill) => (
|
||||
<label
|
||||
key={skill.directory}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(skill.directory)}
|
||||
onChange={() => toggleSelect(skill.directory)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={selected.size === 0}>
|
||||
{t("skills.importSelected", { count: selected.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedSkillsPanel;
|
||||
@@ -62,6 +62,28 @@ export function RequestLogTable() {
|
||||
});
|
||||
};
|
||||
|
||||
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
|
||||
const timestampToLocalDatetime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 将 datetime-local 格式转换为 Unix 时间戳
|
||||
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
|
||||
if (!datetime) return undefined;
|
||||
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
|
||||
if (datetime.length < 16) return undefined;
|
||||
const timestamp = new Date(datetime).getTime();
|
||||
// 验证是否为有效日期
|
||||
if (isNaN(timestamp)) return undefined;
|
||||
return Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
@@ -153,19 +175,16 @@ export function RequestLogTable() {
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
tempFilters.startDate
|
||||
? new Date(tempFilters.startDate * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
? timestampToLocalDatetime(tempFilters.startDate)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
startDate: e.target.value
|
||||
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
startDate: timestamp,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>-</span>
|
||||
<Input
|
||||
@@ -173,19 +192,16 @@ export function RequestLogTable() {
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
tempFilters.endDate
|
||||
? new Date(tempFilters.endDate * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
? timestampToLocalDatetime(tempFilters.endDate)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
endDate: e.target.value
|
||||
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
endDate: timestamp,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,13 +12,7 @@ interface UsageSummaryCardsProps {
|
||||
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - days * 24 * 60 * 60;
|
||||
return { startDate: start, endDate: end };
|
||||
}, [days]);
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
|
||||
const { data: summary, isLoading } = useUsageSummary(days);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalRequests = summary?.totalRequests ?? 0;
|
||||
|
||||
@@ -41,7 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
return {
|
||||
rawDate: stat.date,
|
||||
label: isToday
|
||||
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
|
||||
? pointDate.toLocaleString(dateLocale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: pointDate.toLocaleDateString(dateLocale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -49,28 +54,13 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
hour: pointDate.getHours(),
|
||||
inputTokens: stat.totalInputTokens,
|
||||
outputTokens: stat.totalOutputTokens,
|
||||
cacheCreationTokens: stat.totalCacheCreationTokens,
|
||||
cacheReadTokens: stat.totalCacheReadTokens,
|
||||
cost: parseFloat(stat.totalCost),
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const hourlyData = (() => {
|
||||
if (!isToday) return chartData;
|
||||
const map = new Map<number, (typeof chartData)[number]>();
|
||||
chartData.forEach((point) => {
|
||||
map.set(point.hour ?? 0, point);
|
||||
});
|
||||
return Array.from({ length: 24 }, (_, hour) => {
|
||||
const bucket = map.get(hour);
|
||||
return {
|
||||
label: `${hour.toString().padStart(2, "0")}:00`,
|
||||
inputTokens: bucket?.inputTokens ?? 0,
|
||||
outputTokens: bucket?.outputTokens ?? 0,
|
||||
cost: bucket?.cost ?? 0,
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
const displayData = isToday ? hourlyData : chartData;
|
||||
const displayData = chartData;
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
@@ -131,6 +121,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="colorCacheCreation"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorCacheRead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -182,6 +186,26 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
fill="url(#colorOutput)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="cacheCreationTokens"
|
||||
name={t("usage.cacheCreationTokens", "缓存创建")}
|
||||
stroke="#f97316"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCacheCreation)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="cacheReadTokens"
|
||||
name={t("usage.cacheReadTokens", "缓存命中")}
|
||||
stroke="#a855f7"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCacheRead)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="cost"
|
||||
type="monotone"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
/**
|
||||
* 保存最后一个非 null/undefined 的值
|
||||
* 用于 Dialog 关闭动画期间保持内容显示
|
||||
*
|
||||
* @param value 当前值
|
||||
* @returns 当前值(如果有效)或最后一个有效值
|
||||
*/
|
||||
export function useLastValidValue<T>(value: T | null | undefined): T | null {
|
||||
const ref = useRef<T | null>(null);
|
||||
|
||||
// 同步更新 ref(在渲染期间,不在 useEffect 中)
|
||||
if (value != null) {
|
||||
ref.current = value;
|
||||
}
|
||||
|
||||
// 返回当前值或最后有效值
|
||||
return value ?? ref.current;
|
||||
}
|
||||
@@ -59,3 +59,16 @@ export function useDeleteMcpServer() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从所有应用导入 MCP 服务器
|
||||
*/
|
||||
export function useImportMcpFromApps() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => mcpApi.importFromApps(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type AppType,
|
||||
type DiscoverableSkill,
|
||||
type InstalledSkill,
|
||||
} from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
* 查询所有已安装的 Skills
|
||||
*/
|
||||
export function useInstalledSkills() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "installed"],
|
||||
queryFn: () => skillsApi.getInstalled(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现可安装的 Skills(从仓库获取)
|
||||
*/
|
||||
export function useDiscoverableSkills() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "discoverable"],
|
||||
queryFn: () => skillsApi.discoverAvailable(),
|
||||
staleTime: Infinity, // 无限缓存,直到仓库变化时 invalidate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 Skill
|
||||
*/
|
||||
export function useInstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skill,
|
||||
currentApp,
|
||||
}: {
|
||||
skill: DiscoverableSkill;
|
||||
currentApp: AppType;
|
||||
}) => skillsApi.installUnified(skill, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载 Skill
|
||||
*/
|
||||
export function useUninstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => skillsApi.uninstallUnified(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 Skill 在特定应用的启用状态
|
||||
*/
|
||||
export function useToggleSkillApp() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
app,
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
app: AppType;
|
||||
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: (directories: string[]) => skillsApi.importFromApps(directories),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 辅助类型 ==========
|
||||
|
||||
export type { InstalledSkill, DiscoverableSkill, AppType };
|
||||
+37
-12
@@ -343,7 +343,6 @@
|
||||
"anthropicModel": "Main Model",
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
||||
"reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter Compatibility Mode",
|
||||
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
|
||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||
@@ -423,7 +422,7 @@
|
||||
"cost": "Cost",
|
||||
"perMillion": "(per million)",
|
||||
"trends": "Usage Trends",
|
||||
"rangeToday": "Today (hourly)",
|
||||
"rangeToday": "Last 24 hours (hourly)",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"totalTokens": "Total Tokens",
|
||||
@@ -436,8 +435,8 @@
|
||||
"billingModel": "Billing Model",
|
||||
"inputTokens": "Input",
|
||||
"outputTokens": "Output",
|
||||
"cacheReadTokens": "Cache Read",
|
||||
"cacheCreationTokens": "Cache Write",
|
||||
"cacheReadTokens": "Cache Hit",
|
||||
"cacheCreationTokens": "Cache Creation",
|
||||
"timingInfo": "Duration/TTFT",
|
||||
"status": "Status",
|
||||
"noData": "No data",
|
||||
@@ -453,8 +452,8 @@
|
||||
"displayName": "Display Name",
|
||||
"inputCost": "Input Cost",
|
||||
"outputCost": "Output Cost",
|
||||
"cacheReadCost": "Cache Read",
|
||||
"cacheWriteCost": "Cache Write",
|
||||
"cacheReadCost": "Cache Hit",
|
||||
"cacheWriteCost": "Cache Creation",
|
||||
"deleteConfirmTitle": "Confirm Delete",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
|
||||
"queryFailed": "Query failed",
|
||||
@@ -481,8 +480,8 @@
|
||||
"timeRange": "Time Range",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
"cacheWrite": "Creation",
|
||||
"cacheRead": "Hit"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "Configure Usage Query",
|
||||
@@ -565,6 +564,9 @@
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Management",
|
||||
"import": "Import",
|
||||
"importExisting": "Import Existing",
|
||||
"addMcp": "Add MCP",
|
||||
"claudeTitle": "Claude Code MCP Management",
|
||||
"codexTitle": "Codex MCP Management",
|
||||
"geminiTitle": "Gemini MCP Management",
|
||||
@@ -576,6 +578,8 @@
|
||||
"deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.",
|
||||
"noServers": "No servers yet",
|
||||
"enabledApps": "Enabled Apps",
|
||||
"noImportFound": "No MCP servers to import found. All servers are already managed by CC Switch.",
|
||||
"importSuccess": "Successfully imported {{count}} MCP servers",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -793,8 +797,8 @@
|
||||
},
|
||||
"skills": {
|
||||
"manage": "Skills",
|
||||
"title": "Claude Skills Management",
|
||||
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
|
||||
"title": "Skills Management",
|
||||
"description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"repoManager": "Repository Management",
|
||||
@@ -870,7 +874,25 @@
|
||||
"installed": "Installed",
|
||||
"uninstalled": "Not installed"
|
||||
},
|
||||
"noResults": "No matching skills found"
|
||||
"noResults": "No matching skills found",
|
||||
"noInstalled": "No skills installed",
|
||||
"noInstalledDescription": "Discover and install skills from repositories, or import existing skills",
|
||||
"discover": "Discover Skills",
|
||||
"import": "Import Existing",
|
||||
"importDescription": "Select skills to import into CC Switch unified management",
|
||||
"importSuccess": "Successfully imported {{count}} skills",
|
||||
"importSelected": "Import Selected ({{count}})",
|
||||
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
|
||||
"foundIn": "Found in",
|
||||
"local": "Local",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.",
|
||||
"uninstallInMainPanel": "Please uninstall skills from the main panel",
|
||||
"notFound": "Skill not found",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
"confirmImport": "Confirm Import Provider",
|
||||
@@ -958,7 +980,10 @@
|
||||
"clickToSelect": "Click to select icon"
|
||||
},
|
||||
"migration": {
|
||||
"success": "Configuration migrated successfully"
|
||||
"success": "Configuration migrated successfully",
|
||||
"skillsSuccess": "Automatically imported {{count}} skill(s) into unified management",
|
||||
"skillsFailed": "Failed to auto import skills",
|
||||
"skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)."
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents"
|
||||
|
||||
+37
-12
@@ -343,7 +343,6 @@
|
||||
"anthropicModel": "メインモデル",
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter 互換モード",
|
||||
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
|
||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||
@@ -423,7 +422,7 @@
|
||||
"cost": "コスト",
|
||||
"perMillion": "(100万あたり)",
|
||||
"trends": "利用トレンド",
|
||||
"rangeToday": "今日 (時間別)",
|
||||
"rangeToday": "直近24時間 (時間別)",
|
||||
"rangeLast7Days": "過去7日間",
|
||||
"rangeLast30Days": "過去30日間",
|
||||
"totalTokens": "総トークン数",
|
||||
@@ -436,8 +435,8 @@
|
||||
"billingModel": "課金モデル",
|
||||
"inputTokens": "入力",
|
||||
"outputTokens": "出力",
|
||||
"cacheReadTokens": "キャッシュ読取",
|
||||
"cacheCreationTokens": "キャッシュ書込",
|
||||
"cacheReadTokens": "キャッシュヒット",
|
||||
"cacheCreationTokens": "キャッシュ作成",
|
||||
"timingInfo": "応答時間/TTFT",
|
||||
"status": "ステータス",
|
||||
"noData": "データなし",
|
||||
@@ -453,8 +452,8 @@
|
||||
"displayName": "表示名",
|
||||
"inputCost": "入力コスト",
|
||||
"outputCost": "出力コスト",
|
||||
"cacheReadCost": "キャッシュ読取",
|
||||
"cacheWriteCost": "キャッシュ書込",
|
||||
"cacheReadCost": "キャッシュヒット",
|
||||
"cacheWriteCost": "キャッシュ作成",
|
||||
"deleteConfirmTitle": "削除の確認",
|
||||
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
|
||||
"queryFailed": "照会に失敗しました",
|
||||
@@ -481,8 +480,8 @@
|
||||
"timeRange": "期間",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
"cacheWrite": "作成",
|
||||
"cacheRead": "ヒット"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "利用状況を設定",
|
||||
@@ -565,6 +564,9 @@
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 管理",
|
||||
"import": "インポート",
|
||||
"importExisting": "既存をインポート",
|
||||
"addMcp": "MCPを追加",
|
||||
"claudeTitle": "Claude Code MCP 管理",
|
||||
"codexTitle": "Codex MCP 管理",
|
||||
"geminiTitle": "Gemini MCP 管理",
|
||||
@@ -576,6 +578,8 @@
|
||||
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
|
||||
"noServers": "まだサーバーがありません",
|
||||
"enabledApps": "有効なアプリ",
|
||||
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
|
||||
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -793,8 +797,8 @@
|
||||
},
|
||||
"skills": {
|
||||
"manage": "Skills",
|
||||
"title": "Claude スキル管理",
|
||||
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
|
||||
"title": "Skills 管理",
|
||||
"description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張",
|
||||
"refresh": "更新",
|
||||
"refreshing": "更新中...",
|
||||
"repoManager": "リポジトリ管理",
|
||||
@@ -870,7 +874,25 @@
|
||||
"installed": "インストール済み",
|
||||
"uninstalled": "未インストール"
|
||||
},
|
||||
"noResults": "一致するスキルが見つかりませんでした"
|
||||
"noResults": "一致するスキルが見つかりませんでした",
|
||||
"noInstalled": "インストールされたスキルがありません",
|
||||
"noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください",
|
||||
"discover": "スキルを発見",
|
||||
"import": "既存をインポート",
|
||||
"importDescription": "CC Switch 統合管理にインポートするスキルを選択してください",
|
||||
"importSuccess": "{{count}} 件のスキルをインポートしました",
|
||||
"importSelected": "選択をインポート ({{count}})",
|
||||
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
|
||||
"foundIn": "発見場所",
|
||||
"local": "ローカル",
|
||||
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。",
|
||||
"uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください",
|
||||
"notFound": "スキルが見つかりません",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
"confirmImport": "プロバイダーのインポートを確認",
|
||||
@@ -958,7 +980,10 @@
|
||||
"clickToSelect": "クリックでアイコンを選択"
|
||||
},
|
||||
"migration": {
|
||||
"success": "設定の移行が完了しました"
|
||||
"success": "設定の移行が完了しました",
|
||||
"skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました",
|
||||
"skillsFailed": "スキルの自動インポートに失敗しました",
|
||||
"skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。"
|
||||
},
|
||||
"agents": {
|
||||
"title": "エージェント"
|
||||
|
||||
+37
-12
@@ -343,7 +343,6 @@
|
||||
"anthropicModel": "主模型",
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter 兼容模式",
|
||||
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||
@@ -423,7 +422,7 @@
|
||||
"cost": "成本",
|
||||
"perMillion": "(每百万)",
|
||||
"trends": "使用趋势",
|
||||
"rangeToday": "今天 (按小时)",
|
||||
"rangeToday": "过去 24 小时 (按小时)",
|
||||
"rangeLast7Days": "过去 7 天",
|
||||
"rangeLast30Days": "过去 30 天",
|
||||
"totalTokens": "总 Token 数",
|
||||
@@ -436,8 +435,8 @@
|
||||
"billingModel": "计费模型",
|
||||
"inputTokens": "输入",
|
||||
"outputTokens": "输出",
|
||||
"cacheReadTokens": "缓存读取",
|
||||
"cacheCreationTokens": "缓存写入",
|
||||
"cacheReadTokens": "缓存命中",
|
||||
"cacheCreationTokens": "缓存创建",
|
||||
"timingInfo": "用时/首字",
|
||||
"status": "状态",
|
||||
"noData": "暂无数据",
|
||||
@@ -453,8 +452,8 @@
|
||||
"displayName": "显示名称",
|
||||
"inputCost": "输入成本",
|
||||
"outputCost": "输出成本",
|
||||
"cacheReadCost": "缓存读取",
|
||||
"cacheWriteCost": "缓存写入",
|
||||
"cacheReadCost": "缓存命中",
|
||||
"cacheWriteCost": "缓存创建",
|
||||
"deleteConfirmTitle": "确认删除",
|
||||
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
|
||||
"queryFailed": "查询失败",
|
||||
@@ -481,8 +480,8 @@
|
||||
"timeRange": "时间范围",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
"cacheWrite": "创建",
|
||||
"cacheRead": "命中"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "配置用量查询",
|
||||
@@ -565,6 +564,9 @@
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 管理",
|
||||
"import": "导入",
|
||||
"importExisting": "导入已有",
|
||||
"addMcp": "添加MCP",
|
||||
"claudeTitle": "Claude Code MCP 管理",
|
||||
"codexTitle": "Codex MCP 管理",
|
||||
"geminiTitle": "Gemini MCP 管理",
|
||||
@@ -576,6 +578,8 @@
|
||||
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
|
||||
"noServers": "暂无服务器",
|
||||
"enabledApps": "启用的应用",
|
||||
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
|
||||
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
@@ -793,8 +797,8 @@
|
||||
},
|
||||
"skills": {
|
||||
"manage": "Skills",
|
||||
"title": "Claude Skills 管理",
|
||||
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
|
||||
"title": "Skills 管理",
|
||||
"description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力",
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"repoManager": "仓库管理",
|
||||
@@ -870,7 +874,25 @@
|
||||
"installed": "已安装",
|
||||
"uninstalled": "未安装"
|
||||
},
|
||||
"noResults": "未找到匹配的技能"
|
||||
"noResults": "未找到匹配的技能",
|
||||
"noInstalled": "暂无已安装的技能",
|
||||
"noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能",
|
||||
"discover": "发现技能",
|
||||
"import": "导入已有",
|
||||
"importDescription": "选择要导入到 CC Switch 统一管理的技能",
|
||||
"importSuccess": "成功导入 {{count}} 个技能",
|
||||
"importSelected": "导入已选 ({{count}})",
|
||||
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
|
||||
"foundIn": "发现于",
|
||||
"local": "本地",
|
||||
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。",
|
||||
"uninstallInMainPanel": "请在主面板中卸载技能",
|
||||
"notFound": "未找到技能",
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
"confirmImport": "确认导入供应商配置",
|
||||
@@ -958,7 +980,10 @@
|
||||
"clickToSelect": "点击选择图标"
|
||||
},
|
||||
"migration": {
|
||||
"success": "配置迁移成功"
|
||||
"success": "配置迁移成功",
|
||||
"skillsSuccess": "已自动导入 {{count}} 个技能到统一管理",
|
||||
"skillsFailed": "自动导入技能失败",
|
||||
"skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。"
|
||||
},
|
||||
"agents": {
|
||||
"title": "智能体"
|
||||
|
||||
@@ -119,4 +119,11 @@ export const mcpApi = {
|
||||
): Promise<void> {
|
||||
return await invoke("toggle_mcp_app", { serverId, app, enabled });
|
||||
},
|
||||
|
||||
/**
|
||||
* 从所有应用导入 MCP 服务器
|
||||
*/
|
||||
async importFromApps(): Promise<number> {
|
||||
return await invoke("import_mcp_from_apps");
|
||||
},
|
||||
};
|
||||
|
||||
+102
-1
@@ -1,5 +1,51 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
|
||||
/** Skill 应用启用状态 */
|
||||
export interface SkillApps {
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
}
|
||||
|
||||
/** 已安装的 Skill(v3.10.0+ 统一结构) */
|
||||
export interface InstalledSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
directory: string;
|
||||
repoOwner?: string;
|
||||
repoName?: string;
|
||||
repoBranch?: string;
|
||||
readmeUrl?: string;
|
||||
apps: SkillApps;
|
||||
installedAt: number;
|
||||
}
|
||||
|
||||
/** 可发现的 Skill(来自仓库) */
|
||||
export interface DiscoverableSkill {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
directory: string;
|
||||
readmeUrl?: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
repoBranch: string;
|
||||
}
|
||||
|
||||
/** 未管理的 Skill(用于导入) */
|
||||
export interface UnmanagedSkill {
|
||||
directory: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
foundIn: string[];
|
||||
}
|
||||
|
||||
/** 技能对象(兼容旧 API) */
|
||||
export interface Skill {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -12,6 +58,7 @@ export interface Skill {
|
||||
repoBranch?: string;
|
||||
}
|
||||
|
||||
/** 仓库配置 */
|
||||
export interface SkillRepo {
|
||||
owner: string;
|
||||
name: string;
|
||||
@@ -19,9 +66,56 @@ export interface SkillRepo {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
// ========== API ==========
|
||||
|
||||
export const skillsApi = {
|
||||
// ========== 统一管理 API (v3.10.0+) ==========
|
||||
|
||||
/** 获取所有已安装的 Skills */
|
||||
async getInstalled(): Promise<InstalledSkill[]> {
|
||||
return await invoke("get_installed_skills");
|
||||
},
|
||||
|
||||
/** 安装 Skill(统一安装) */
|
||||
async installUnified(
|
||||
skill: DiscoverableSkill,
|
||||
currentApp: AppType,
|
||||
): Promise<InstalledSkill> {
|
||||
return await invoke("install_skill_unified", { skill, currentApp });
|
||||
},
|
||||
|
||||
/** 卸载 Skill(统一卸载) */
|
||||
async uninstallUnified(id: string): Promise<boolean> {
|
||||
return await invoke("uninstall_skill_unified", { id });
|
||||
},
|
||||
|
||||
/** 切换 Skill 的应用启用状态 */
|
||||
async toggleApp(
|
||||
id: string,
|
||||
app: AppType,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
return await invoke("toggle_skill_app", { id, app, enabled });
|
||||
},
|
||||
|
||||
/** 扫描未管理的 Skills */
|
||||
async scanUnmanaged(): Promise<UnmanagedSkill[]> {
|
||||
return await invoke("scan_unmanaged_skills");
|
||||
},
|
||||
|
||||
/** 从应用目录导入 Skills */
|
||||
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
|
||||
return await invoke("import_skills_from_apps", { directories });
|
||||
},
|
||||
|
||||
/** 发现可安装的 Skills(从仓库获取) */
|
||||
async discoverAvailable(): Promise<DiscoverableSkill[]> {
|
||||
return await invoke("discover_available_skills");
|
||||
},
|
||||
|
||||
// ========== 兼容旧 API ==========
|
||||
|
||||
/** 获取技能列表(兼容旧 API) */
|
||||
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
||||
if (app === "claude") {
|
||||
return await invoke("get_skills");
|
||||
@@ -29,6 +123,7 @@ export const skillsApi = {
|
||||
return await invoke("get_skills_for_app", { app });
|
||||
},
|
||||
|
||||
/** 安装技能(兼容旧 API) */
|
||||
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
||||
if (app === "claude") {
|
||||
return await invoke("install_skill", { directory });
|
||||
@@ -36,6 +131,7 @@ export const skillsApi = {
|
||||
return await invoke("install_skill_for_app", { app, directory });
|
||||
},
|
||||
|
||||
/** 卸载技能(兼容旧 API) */
|
||||
async uninstall(
|
||||
directory: string,
|
||||
app: AppType = "claude",
|
||||
@@ -46,14 +142,19 @@ export const skillsApi = {
|
||||
return await invoke("uninstall_skill_for_app", { app, directory });
|
||||
},
|
||||
|
||||
// ========== 仓库管理 ==========
|
||||
|
||||
/** 获取仓库列表 */
|
||||
async getRepos(): Promise<SkillRepo[]> {
|
||||
return await invoke("get_skill_repos");
|
||||
},
|
||||
|
||||
/** 添加仓库 */
|
||||
async addRepo(repo: SkillRepo): Promise<boolean> {
|
||||
return await invoke("add_skill_repo", { repo });
|
||||
},
|
||||
|
||||
/** 删除仓库 */
|
||||
async removeRepo(owner: string, name: string): Promise<boolean> {
|
||||
return await invoke("remove_skill_repo", { owner, name });
|
||||
},
|
||||
|
||||
@@ -49,8 +49,11 @@ export const usageApi = {
|
||||
return invoke("get_usage_summary", { startDate, endDate });
|
||||
},
|
||||
|
||||
getUsageTrends: async (days: number): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { days });
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { startDate, endDate });
|
||||
},
|
||||
|
||||
getProviderStats: async (): Promise<ProviderStats[]> => {
|
||||
|
||||
+27
-6
@@ -5,8 +5,7 @@ import type { LogFilters } from "@/types/usage";
|
||||
// Query keys
|
||||
export const usageKeys = {
|
||||
all: ["usage"] as const,
|
||||
summary: (startDate?: number, endDate?: number) =>
|
||||
[...usageKeys.all, "summary", startDate, endDate] as const,
|
||||
summary: (days: number) => [...usageKeys.all, "summary", days] as const,
|
||||
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
|
||||
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
|
||||
modelStats: () => [...usageKeys.all, "model-stats"] as const,
|
||||
@@ -19,18 +18,34 @@ export const usageKeys = {
|
||||
[...usageKeys.all, "limits", providerId, appType] as const,
|
||||
};
|
||||
|
||||
const getWindow = (days: number) => {
|
||||
const endDate = Math.floor(Date.now() / 1000);
|
||||
const startDate = endDate - days * 24 * 60 * 60;
|
||||
return { startDate, endDate };
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(startDate?: number, endDate?: number) {
|
||||
export function useUsageSummary(days: number) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(startDate, endDate),
|
||||
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
|
||||
queryKey: usageKeys.summary(days),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageSummary(startDate, endDate);
|
||||
},
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false, // 后台不刷新
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageTrends(days: number) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(days),
|
||||
queryFn: () => usageApi.getUsageTrends(days),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageTrends(startDate, endDate);
|
||||
},
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +53,8 @@ export function useProviderStats() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(),
|
||||
queryFn: usageApi.getProviderStats,
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,6 +62,8 @@ export function useModelStats() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(),
|
||||
queryFn: usageApi.getModelStats,
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +75,8 @@ export function useRequestLogs(
|
||||
return useQuery({
|
||||
queryKey: usageKeys.logs(filters, page, pageSize),
|
||||
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user