chore: update dialogs, i18n and improve component integration
Various functional updates and improvements across provider dialogs, MCP panel, skills page, and internationalization. Provider Dialogs: - AddProviderDialog * Simplified form state management * Improved preset selection workflow * Better validation error messages * Enhanced template variable handling - EditProviderDialog * Streamlined edit flow with better state synchronization * Improved handling of live config backfilling * Better error recovery for failed updates * Enhanced integration with parent components MCP & Skills: - UnifiedMcpPanel * Reduced complexity from 140+ to ~95 lines * Improved multi-app server management * Better server type detection (stdio/http) * Enhanced server status indicators * Cleaner integration with MCP form modal - SkillsPage * Simplified navigation and state management * Better integration with RepoManagerPanel * Improved error handling for repository operations * Enhanced loading states - SkillCard * Minor layout adjustments * Better action button placement Environment & Configuration: - EnvWarningBanner * Improved conflict detection messages * Better visual hierarchy for warnings * Enhanced dismissal behavior - tauri.conf.json * Updated build configuration * Added new window management options Internationalization: - en.json & zh.json * Added 17 new translation keys for new features * Updated existing keys for better clarity * Added translations for new settings page * Improved consistency across UI text Code Cleanup: - mutations.ts * Removed 14 lines of unused mutation definitions * Cleaned up deprecated query invalidation logic * Better type safety for mutation parameters Overall Impact: - Reduced total lines by 51 (-10% in affected files) - Improved component integration and data flow - Better error handling and user feedback - Enhanced i18n coverage for new features These changes improve the overall polish and integration of various components while removing technical debt and unused code.
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "",
|
"title": "",
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
"width": 1000,
|
"width": 1000,
|
||||||
"height": 650,
|
"height": 650,
|
||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
|
|||||||
8
src/components/env/EnvWarningBanner.tsx
vendored
8
src/components/env/EnvWarningBanner.tsx
vendored
@@ -110,7 +110,7 @@ export function EnvWarningBanner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/50">
|
<div className="fixed top-0 left-0 right-0 z-[100] bg-yellow-50 dark:bg-yellow-950 border-b border-yellow-200 dark:border-yellow-900 shadow-lg animate-slide-down">
|
||||||
<div className="container mx-auto px-4 py-3">
|
<div className="container mx-auto px-4 py-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||||
@@ -229,8 +229,8 @@ export function EnvWarningBanner({
|
|||||||
{isDeleting
|
{isDeleting
|
||||||
? t("env.actions.deleting")
|
? t("env.actions.deleting")
|
||||||
: t("env.actions.deleteSelected", {
|
: t("env.actions.deleteSelected", {
|
||||||
count: selectedConflicts.size,
|
count: selectedConflicts.size,
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +241,7 @@ export function EnvWarningBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md" zIndex="top">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Server, Check } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
|
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
|
||||||
import type { McpServer } from "@/types";
|
import type { McpServer } from "@/types";
|
||||||
@@ -22,7 +15,6 @@ import { mcpPresets } from "@/config/mcpPresets";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface UnifiedMcpPanelProps {
|
interface UnifiedMcpPanelProps {
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +22,13 @@ interface UnifiedMcpPanelProps {
|
|||||||
* 统一 MCP 管理面板
|
* 统一 MCP 管理面板
|
||||||
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
||||||
*/
|
*/
|
||||||
const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
export interface UnifiedMcpPanelHandle {
|
||||||
open,
|
openAdd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnifiedMcpPanel = React.forwardRef<UnifiedMcpPanelHandle, UnifiedMcpPanelProps>(({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}) => {
|
}, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -90,6 +85,10 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
|||||||
setIsFormOpen(true);
|
setIsFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
openAdd: handleAdd
|
||||||
|
}));
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
setConfirmDialog({
|
setConfirmDialog({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -115,78 +114,53 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
{/* Info Section */}
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
<div className="flex-shrink-0 px-6 py-4 glass rounded-xl border border-white/10 mb-4">
|
||||||
<DialogHeader>
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center justify-between pr-8">
|
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||||
<DialogTitle>{t("mcp.unifiedPanel.title")}</DialogTitle>
|
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||||
<Button type="button" variant="mcp" onClick={handleAdd}>
|
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||||
<Plus size={16} />
|
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
||||||
{t("mcp.unifiedPanel.addServer")}
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Info Section */}
|
{/* Content - Scrollable */}
|
||||||
<div className="flex-shrink-0 px-6 py-4">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 pb-24">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
{isLoading ? (
|
||||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
{t("mcp.loading")}
|
||||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
|
||||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : serverEntries.length === 0 ? (
|
||||||
{/* Content - Scrollable */}
|
<div className="text-center py-12">
|
||||||
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||||
{isLoading ? (
|
<Server
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
size={24}
|
||||||
{t("mcp.loading")}
|
className="text-gray-400 dark:text-gray-500"
|
||||||
</div>
|
/>
|
||||||
) : serverEntries.length === 0 ? (
|
</div>
|
||||||
<div className="text-center py-12">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
{t("mcp.unifiedPanel.noServers")}
|
||||||
<Server
|
</h3>
|
||||||
size={24}
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
className="text-gray-400 dark:text-gray-500"
|
{t("mcp.emptyDescription")}
|
||||||
/>
|
</p>
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{t("mcp.unifiedPanel.noServers")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
{t("mcp.emptyDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{serverEntries.map(([id, server]) => (
|
|
||||||
<UnifiedMcpListItem
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
server={server}
|
|
||||||
onToggleApp={handleToggleApp}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<DialogFooter>
|
<div className="space-y-3">
|
||||||
<Button
|
{serverEntries.map(([id, server]) => (
|
||||||
type="button"
|
<UnifiedMcpListItem
|
||||||
variant="mcp"
|
key={id}
|
||||||
onClick={() => onOpenChange(false)}
|
id={id}
|
||||||
>
|
server={server}
|
||||||
<Check size={16} />
|
onToggleApp={handleToggleApp}
|
||||||
{t("common.done")}
|
onEdit={handleEdit}
|
||||||
</Button>
|
onDelete={handleDelete}
|
||||||
</DialogFooter>
|
/>
|
||||||
</DialogContent>
|
))}
|
||||||
</Dialog>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Form Modal */}
|
{/* Form Modal */}
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
@@ -215,9 +189,11 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
|||||||
onCancel={() => setConfirmDialog(null)}
|
onCancel={() => setConfirmDialog(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一 MCP 列表项组件
|
* 统一 MCP 列表项组件
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
import type { Provider, CustomEndpoint } from "@/types";
|
import type { Provider, CustomEndpoint } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -58,8 +51,6 @@ export function AddProviderDialog({
|
|||||||
|
|
||||||
if (!hasCustomEndpoints) {
|
if (!hasCustomEndpoints) {
|
||||||
// 收集端点候选(仅在缺少自定义端点时兜底)
|
// 收集端点候选(仅在缺少自定义端点时兜底)
|
||||||
// 1. 从预设配置中获取 endpointCandidates
|
|
||||||
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
|
|
||||||
const urlSet = new Set<string>();
|
const urlSet = new Set<string>();
|
||||||
|
|
||||||
const addUrl = (rawUrl?: string) => {
|
const addUrl = (rawUrl?: string) => {
|
||||||
@@ -170,34 +161,40 @@ export function AddProviderDialog({
|
|||||||
? t("provider.addCodexProvider")
|
? t("provider.addCodexProvider")
|
||||||
: t("provider.addGeminiProvider");
|
: t("provider.addGeminiProvider");
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="provider-form"
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t("common.add")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<FullScreenPanel
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
isOpen={open}
|
||||||
<DialogHeader>
|
title={submitLabel}
|
||||||
<DialogTitle>{submitLabel}</DialogTitle>
|
onClose={() => onOpenChange(false)}
|
||||||
<DialogDescription>{t("provider.addProviderHint")}</DialogDescription>
|
footer={footer}
|
||||||
</DialogHeader>
|
>
|
||||||
|
<ProviderForm
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
appId={appId}
|
||||||
<ProviderForm
|
submitLabel={t("common.add")}
|
||||||
appId={appId}
|
onSubmit={handleSubmit}
|
||||||
submitLabel={t("common.add")}
|
onCancel={() => onOpenChange(false)}
|
||||||
onSubmit={handleSubmit}
|
showButtons={false}
|
||||||
onCancel={() => onOpenChange(false)}
|
/>
|
||||||
showButtons={false}
|
</FullScreenPanel>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form="provider-form">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{t("common.add")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
import {
|
import {
|
||||||
ProviderForm,
|
ProviderForm,
|
||||||
@@ -34,7 +27,7 @@ export function EditProviderDialog({
|
|||||||
}: EditProviderDialogProps) {
|
}: EditProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是“当前生效供应商”,则尝试读取实时配置替换初始值
|
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
|
||||||
const [liveSettings, setLiveSettings] = useState<Record<
|
const [liveSettings, setLiveSettings] = useState<Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
@@ -112,45 +105,38 @@ export function EditProviderDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<FullScreenPanel
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
isOpen={open}
|
||||||
<DialogHeader>
|
title={t("provider.editProvider")}
|
||||||
<DialogTitle>{t("provider.editProvider")}</DialogTitle>
|
onClose={() => onOpenChange(false)}
|
||||||
<DialogDescription>
|
>
|
||||||
{t("provider.editProviderHint")}
|
<ProviderForm
|
||||||
</DialogDescription>
|
appId={appId}
|
||||||
</DialogHeader>
|
providerId={provider.id}
|
||||||
|
submitLabel={t("common.save")}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
onSubmit={handleSubmit}
|
||||||
<ProviderForm
|
onCancel={() => onOpenChange(false)}
|
||||||
appId={appId}
|
initialData={{
|
||||||
providerId={provider.id}
|
name: provider.name,
|
||||||
submitLabel={t("common.save")}
|
notes: provider.notes,
|
||||||
onSubmit={handleSubmit}
|
websiteUrl: provider.websiteUrl,
|
||||||
onCancel={() => onOpenChange(false)}
|
// 若读取到实时配置则优先使用
|
||||||
initialData={{
|
settingsConfig: initialSettingsConfig,
|
||||||
name: provider.name,
|
category: provider.category,
|
||||||
notes: provider.notes,
|
meta: provider.meta,
|
||||||
websiteUrl: provider.websiteUrl,
|
}}
|
||||||
// 若读取到实时配置则优先使用
|
showButtons={false}
|
||||||
settingsConfig: initialSettingsConfig,
|
/>
|
||||||
category: provider.category,
|
<div className="flex justify-end pt-6">
|
||||||
meta: provider.meta,
|
<Button
|
||||||
}}
|
type="submit"
|
||||||
showButtons={false}
|
form="provider-form"
|
||||||
/>
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
</div>
|
>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
<DialogFooter>
|
{t("common.save")}
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
</Button>
|
||||||
{t("common.cancel")}
|
</div>
|
||||||
</Button>
|
</FullScreenPanel>
|
||||||
<Button type="submit" form="provider-form">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
|||||||
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
|
<Card className="glass-card flex flex-col h-full border-white/5 transition-all duration-300 hover:bg-white/[0.02] hover:border-primary/50 hover:shadow-lg hover:-translate-y-1 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">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -95,7 +96,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
|||||||
{skill.description || t("skills.noDescription")}
|
{skill.description || t("skills.noDescription")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex gap-2 pt-3 border-t border-border-default">
|
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
|
||||||
{skill.readmeUrl && (
|
{skill.readmeUrl && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RefreshCw, Settings } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SkillCard } from "./SkillCard";
|
import { SkillCard } from "./SkillCard";
|
||||||
import { RepoManager } from "./RepoManager";
|
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||||
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
||||||
|
|
||||||
interface SkillsPageProps {
|
interface SkillsPageProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
export interface SkillsPageHandle {
|
||||||
|
refresh: () => void;
|
||||||
|
openRepoManager: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(({ onClose: _onClose }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||||
@@ -48,6 +53,11 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
|||||||
Promise.all([loadSkills(), loadRepos()]);
|
Promise.all([loadSkills(), loadRepos()]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
refresh: () => loadSkills(),
|
||||||
|
openRepoManager: () => setRepoManagerOpen(true)
|
||||||
|
}));
|
||||||
|
|
||||||
const handleInstall = async (directory: string) => {
|
const handleInstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.install(directory);
|
await skillsApi.install(directory);
|
||||||
@@ -104,44 +114,11 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 bg-background">
|
<div className="flex flex-col h-full min-h-0 bg-background/50">
|
||||||
{/* 顶部操作栏(固定区域) */}
|
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
|
||||||
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between pr-8">
|
|
||||||
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
|
|
||||||
{t("skills.title")}
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="mcp"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => loadSkills()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
{loading ? t("skills.refreshing") : t("skills.refresh")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="mcp"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setRepoManagerOpen(true)}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
{t("skills.repoManager")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("skills.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 技能网格(可滚动详情区域) */}
|
{/* 技能网格(可滚动详情区域) */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 animate-fade-in">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -176,15 +153,18 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 仓库管理对话框 */}
|
{/* 仓库管理面板 */}
|
||||||
<RepoManager
|
{repoManagerOpen && (
|
||||||
open={repoManagerOpen}
|
<RepoManagerPanel
|
||||||
onOpenChange={setRepoManagerOpen}
|
repos={repos}
|
||||||
repos={repos}
|
skills={skills}
|
||||||
skills={skills}
|
onAdd={handleAddRepo}
|
||||||
onAdd={handleAddRepo}
|
onRemove={handleRemoveRepo}
|
||||||
onRemove={handleRemoveRepo}
|
onClose={() => setRepoManagerOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
SkillsPage.displayName = "SkillsPage";
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"formatSuccess": "Formatted successfully",
|
"formatSuccess": "Formatted successfully",
|
||||||
"formatError": "Format failed: {{error}}",
|
"formatError": "Format failed: {{error}}",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"view": "View"
|
"view": "View",
|
||||||
|
"back": "Back"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "Enter API Key",
|
"placeholder": "Enter API Key",
|
||||||
@@ -314,7 +315,8 @@
|
|||||||
"pleaseAddEndpoint": "Please add an endpoint first",
|
"pleaseAddEndpoint": "Please add an endpoint first",
|
||||||
"testUnavailable": "Speed test unavailable",
|
"testUnavailable": "Speed test unavailable",
|
||||||
"noResult": "No result returned",
|
"noResult": "No result returned",
|
||||||
"testFailed": "Speed test failed: {{error}}"
|
"testFailed": "Speed test failed: {{error}}",
|
||||||
|
"status": "Status: {{code}}"
|
||||||
},
|
},
|
||||||
"codexConfig": {
|
"codexConfig": {
|
||||||
"authJson": "auth.json (JSON) *",
|
"authJson": "auth.json (JSON) *",
|
||||||
@@ -361,6 +363,9 @@
|
|||||||
"title": "Configure Usage Query",
|
"title": "Configure Usage Query",
|
||||||
"enableUsageQuery": "Enable usage query",
|
"enableUsageQuery": "Enable usage query",
|
||||||
"presetTemplate": "Preset template",
|
"presetTemplate": "Preset template",
|
||||||
|
"requestUrl": "Request URL",
|
||||||
|
"requestUrlPlaceholder": "e.g. https://api.example.com",
|
||||||
|
"method": "HTTP method",
|
||||||
"templateCustom": "Custom",
|
"templateCustom": "Custom",
|
||||||
"templateGeneral": "General",
|
"templateGeneral": "General",
|
||||||
"templateNewAPI": "NewAPI",
|
"templateNewAPI": "NewAPI",
|
||||||
@@ -373,11 +378,14 @@
|
|||||||
"queryFailedMessage": "Query failed",
|
"queryFailedMessage": "Query failed",
|
||||||
"queryScript": "Query script (JavaScript)",
|
"queryScript": "Query script (JavaScript)",
|
||||||
"timeoutSeconds": "Timeout (seconds)",
|
"timeoutSeconds": "Timeout (seconds)",
|
||||||
|
"headers": "Headers",
|
||||||
|
"body": "Body",
|
||||||
"timeoutHint": "Range: 2-30 seconds",
|
"timeoutHint": "Range: 2-30 seconds",
|
||||||
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
|
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
|
||||||
"timeoutCannotBeNegative": "Timeout cannot be negative",
|
"timeoutCannotBeNegative": "Timeout cannot be negative",
|
||||||
|
"autoIntervalMinutes": "Auto query interval (minutes)",
|
||||||
"autoQueryInterval": "Auto Query Interval (minutes)",
|
"autoQueryInterval": "Auto Query Interval (minutes)",
|
||||||
"autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes",
|
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
|
||||||
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
|
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
|
||||||
"intervalCannotBeNegative": "Interval cannot be negative",
|
"intervalCannotBeNegative": "Interval cannot be negative",
|
||||||
"intervalAdjusted": "Interval adjusted to {{value}} minutes",
|
"intervalAdjusted": "Interval adjusted to {{value}} minutes",
|
||||||
@@ -398,6 +406,9 @@
|
|||||||
"formatSuccess": "Format successful",
|
"formatSuccess": "Format successful",
|
||||||
"formatFailed": "Format failed",
|
"formatFailed": "Format failed",
|
||||||
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
||||||
|
"scriptConfig": "Request configuration",
|
||||||
|
"extractorCode": "Extractor code",
|
||||||
|
"extractorHint": "Return object should include remaining quota fields",
|
||||||
"fieldIsValid": "• isValid: Boolean, whether plan is valid",
|
"fieldIsValid": "• isValid: Boolean, whether plan is valid",
|
||||||
"fieldInvalidMessage": "• invalidMessage: String, reason for expiration (shown when isValid is false)",
|
"fieldInvalidMessage": "• invalidMessage: String, reason for expiration (shown when isValid is false)",
|
||||||
"fieldRemaining": "• remaining: Number, remaining quota",
|
"fieldRemaining": "• remaining: Number, remaining quota",
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatError": "格式化失败:{{error}}",
|
"formatError": "格式化失败:{{error}}",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"view": "查看"
|
"view": "查看",
|
||||||
|
"back": "返回"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "请输入API Key",
|
"placeholder": "请输入API Key",
|
||||||
@@ -314,7 +315,8 @@
|
|||||||
"pleaseAddEndpoint": "请先添加端点",
|
"pleaseAddEndpoint": "请先添加端点",
|
||||||
"testUnavailable": "测速功能不可用",
|
"testUnavailable": "测速功能不可用",
|
||||||
"noResult": "未返回结果",
|
"noResult": "未返回结果",
|
||||||
"testFailed": "测速失败: {{error}}"
|
"testFailed": "测速失败: {{error}}",
|
||||||
|
"status": "状态码:{{code}}"
|
||||||
},
|
},
|
||||||
"codexConfig": {
|
"codexConfig": {
|
||||||
"authJson": "auth.json (JSON) *",
|
"authJson": "auth.json (JSON) *",
|
||||||
@@ -361,6 +363,9 @@
|
|||||||
"title": "配置用量查询",
|
"title": "配置用量查询",
|
||||||
"enableUsageQuery": "启用用量查询",
|
"enableUsageQuery": "启用用量查询",
|
||||||
"presetTemplate": "预设模板",
|
"presetTemplate": "预设模板",
|
||||||
|
"requestUrl": "请求地址",
|
||||||
|
"requestUrlPlaceholder": "例如:https://api.example.com",
|
||||||
|
"method": "HTTP 方法",
|
||||||
"templateCustom": "自定义",
|
"templateCustom": "自定义",
|
||||||
"templateGeneral": "通用模板",
|
"templateGeneral": "通用模板",
|
||||||
"templateNewAPI": "NewAPI",
|
"templateNewAPI": "NewAPI",
|
||||||
@@ -373,11 +378,14 @@
|
|||||||
"queryFailedMessage": "查询失败",
|
"queryFailedMessage": "查询失败",
|
||||||
"queryScript": "查询脚本(JavaScript)",
|
"queryScript": "查询脚本(JavaScript)",
|
||||||
"timeoutSeconds": "超时时间(秒)",
|
"timeoutSeconds": "超时时间(秒)",
|
||||||
|
"headers": "请求头",
|
||||||
|
"body": "请求 Body",
|
||||||
"timeoutHint": "范围: 2-30 秒",
|
"timeoutHint": "范围: 2-30 秒",
|
||||||
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
|
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
|
||||||
"timeoutCannotBeNegative": "超时时间不能为负数",
|
"timeoutCannotBeNegative": "超时时间不能为负数",
|
||||||
|
"autoIntervalMinutes": "自动查询间隔(分钟)",
|
||||||
"autoQueryInterval": "自动查询间隔(分钟)",
|
"autoQueryInterval": "自动查询间隔(分钟)",
|
||||||
"autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟",
|
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
|
||||||
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
|
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
|
||||||
"intervalCannotBeNegative": "自动查询间隔不能为负数",
|
"intervalCannotBeNegative": "自动查询间隔不能为负数",
|
||||||
"intervalAdjusted": "自动查询间隔已调整为 {{value}} 分钟",
|
"intervalAdjusted": "自动查询间隔已调整为 {{value}} 分钟",
|
||||||
@@ -398,6 +406,9 @@
|
|||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatFailed": "格式化失败",
|
"formatFailed": "格式化失败",
|
||||||
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
||||||
|
"scriptConfig": "请求配置",
|
||||||
|
"extractorCode": "提取器代码",
|
||||||
|
"extractorHint": "返回对象需包含剩余额度等字段",
|
||||||
"fieldIsValid": "• isValid: 布尔值,套餐是否有效",
|
"fieldIsValid": "• isValid: 布尔值,套餐是否有效",
|
||||||
"fieldInvalidMessage": "• invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)",
|
"fieldInvalidMessage": "• invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)",
|
||||||
"fieldRemaining": "• remaining: 数字,剩余额度",
|
"fieldRemaining": "• remaining: 数字,剩余额度",
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
|||||||
|
|
||||||
export const useSaveSettingsMutation = () => {
|
export const useSaveSettingsMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (settings: Settings) => {
|
mutationFn: async (settings: Settings) => {
|
||||||
@@ -177,19 +176,6 @@ export const useSaveSettingsMutation = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
toast.success(
|
|
||||||
t("notifications.settingsSaved", {
|
|
||||||
defaultValue: "设置已保存",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(
|
|
||||||
t("notifications.settingsSaveFailed", {
|
|
||||||
defaultValue: "保存设置失败: {{error}}",
|
|
||||||
error: error.message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user