mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user