From b88eb88608953e5ea0128588723510dd8969dea6 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 16 Oct 2025 10:49:56 +0800 Subject: [PATCH] feat: complete stage 2 core refactor --- docs/REFACTORING_MASTER_PLAN.md | 16 +- src/App.tsx | 614 ++++++++---------- src/components/AddProviderModal.tsx | 37 -- src/components/AppSwitcher.tsx | 2 +- src/components/EditProviderModal.tsx | 73 --- src/components/mode-toggle.tsx | 58 ++ .../providers/AddProviderDialog.tsx | 77 +++ .../providers/EditProviderDialog.tsx | 84 +++ src/components/providers/ProviderActions.tsx | 75 +++ src/components/providers/ProviderCard.tsx | 155 +++++ .../providers/ProviderEmptyState.tsx | 32 + src/components/providers/ProviderList.tsx | 153 +++++ .../providers/forms/ProviderForm.tsx | 166 +++++ src/components/theme-provider.tsx | 120 ++++ src/components/ui/dropdown-menu.tsx | 203 ++++++ src/hooks/useDragSort.ts | 102 +++ src/main.tsx | 15 +- 17 files changed, 1521 insertions(+), 461 deletions(-) delete mode 100644 src/components/AddProviderModal.tsx delete mode 100644 src/components/EditProviderModal.tsx create mode 100644 src/components/mode-toggle.tsx create mode 100644 src/components/providers/AddProviderDialog.tsx create mode 100644 src/components/providers/EditProviderDialog.tsx create mode 100644 src/components/providers/ProviderActions.tsx create mode 100644 src/components/providers/ProviderCard.tsx create mode 100644 src/components/providers/ProviderEmptyState.tsx create mode 100644 src/components/providers/ProviderList.tsx create mode 100644 src/components/providers/forms/ProviderForm.tsx create mode 100644 src/components/theme-provider.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/hooks/useDragSort.ts diff --git a/docs/REFACTORING_MASTER_PLAN.md b/docs/REFACTORING_MASTER_PLAN.md index caf5fa8..65a9d8d 100644 --- a/docs/REFACTORING_MASTER_PLAN.md +++ b/docs/REFACTORING_MASTER_PLAN.md @@ -871,7 +871,7 @@ export function useDragSort( | ---------- | -------------- | ------------ | ---------------------------- | | **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 | | **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 | -| **阶段 2** | 重构核心功能 | 3-4 天 | App.tsx、ProviderList 完成 | +| **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 | | **阶段 3** | 重构设置和辅助 | 2-3 天 | SettingsDialog、通知系统完成 | | **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 | | **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 | @@ -1366,13 +1366,13 @@ export type ProviderFormData = z.infer; #### 任务清单 -- [ ] 更新 `main.tsx` (添加 Providers) -- [ ] 创建主题 Provider -- [ ] 重构 `App.tsx` (412行 → ~100行) -- [ ] 拆分 ProviderList (4个组件) -- [ ] 创建 `useDragSort` Hook -- [ ] 重构表单组件 (使用 react-hook-form) -- [ ] 创建 AddProvider / EditProvider Dialog +- [x] 更新 `main.tsx` (添加 Providers) +- [x] 创建主题 Provider +- [x] 重构 `App.tsx` (412行 → ~100行) +- [x] 拆分 ProviderList (4个组件) +- [x] 创建 `useDragSort` Hook +- [x] 重构表单组件 (使用 react-hook-form) +- [x] 创建 AddProvider / EditProvider Dialog #### 详细步骤 diff --git a/src/App.tsx b/src/App.tsx index c50d259..5613217 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,399 +1,335 @@ -import { useState, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Provider } from "./types"; -import { AppType } from "./lib/tauri-api"; -import ProviderList from "./components/ProviderList"; -import AddProviderModal from "./components/AddProviderModal"; -import EditProviderModal from "./components/EditProviderModal"; -import { ConfirmDialog } from "./components/ConfirmDialog"; -import { AppSwitcher } from "./components/AppSwitcher"; -import SettingsModal from "./components/SettingsModal"; -import { UpdateBadge } from "./components/UpdateBadge"; -import { Plus, Settings, Moon, Sun } from "lucide-react"; -import McpPanel from "./components/mcp/McpPanel"; -import { buttonStyles } from "./lib/styles"; -import { useDarkMode } from "./hooks/useDarkMode"; -import { extractErrorMessage } from "./utils/errorUtils"; +import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; +import { Plus, Settings } from "lucide-react"; +import type { Provider, UsageScript } from "@/types"; +import { + useProvidersQuery, + useAddProviderMutation, + useUpdateProviderMutation, + useDeleteProviderMutation, + useSwitchProviderMutation, +} from "@/lib/query"; +import { providersApi, type AppType } from "@/lib/api"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { AppSwitcher } from "@/components/AppSwitcher"; +import { ModeToggle } from "@/components/mode-toggle"; +import { ProviderList } from "@/components/providers/ProviderList"; +import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; +import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import SettingsModal from "@/components/SettingsModal"; +import { UpdateBadge } from "@/components/UpdateBadge"; +import UsageScriptModal from "@/components/UsageScriptModal"; +import McpPanel from "@/components/mcp/McpPanel"; +import { Button } from "@/components/ui/button"; + +interface ProviderSwitchEvent { + appType: string; + providerId: string; +} function App() { const { t } = useTranslation(); - const { isDarkMode, toggleDarkMode } = useDarkMode(); + const queryClient = useQueryClient(); + const [activeApp, setActiveApp] = useState("claude"); - const [providers, setProviders] = useState>({}); - const [currentProviderId, setCurrentProviderId] = useState(""); - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [editingProviderId, setEditingProviderId] = useState( - null, - ); - const [notification, setNotification] = useState<{ - message: string; - type: "success" | "error"; - } | null>(null); - const [isNotificationVisible, setIsNotificationVisible] = useState(false); - const [confirmDialog, setConfirmDialog] = useState<{ - isOpen: boolean; - title: string; - message: string; - onConfirm: () => void; - } | null>(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isAddOpen, setIsAddOpen] = useState(false); const [isMcpOpen, setIsMcpOpen] = useState(false); - const timeoutRef = useRef | null>(null); + const [editingProvider, setEditingProvider] = useState(null); + const [usageProvider, setUsageProvider] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); - // 设置通知的辅助函数 - const showNotification = ( - message: string, - type: "success" | "error", - duration = 3000, - ) => { - // 清除之前的定时器 - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + const { data, isLoading, refetch } = useProvidersQuery(activeApp); + const providers = useMemo(() => data?.providers ?? {}, [data]); + const currentProviderId = data?.currentProviderId ?? ""; - // 立即显示通知 - setNotification({ message, type }); - setIsNotificationVisible(true); + const addProviderMutation = useAddProviderMutation(activeApp); + const updateProviderMutation = useUpdateProviderMutation(activeApp); + const deleteProviderMutation = useDeleteProviderMutation(activeApp); + const switchProviderMutation = useSwitchProviderMutation(activeApp); - // 设置淡出定时器 - timeoutRef.current = setTimeout(() => { - setIsNotificationVisible(false); - // 等待淡出动画完成后清除通知 - setTimeout(() => { - setNotification(null); - timeoutRef.current = null; - }, 300); // 与CSS动画时间匹配 - }, duration); - }; - - // 加载供应商列表 useEffect(() => { - loadProviders(); - }, [activeApp]); // 当切换应用时重新加载 - - // 清理定时器 - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - // 监听托盘切换事件(包括菜单切换) - useEffect(() => { - let unlisten: (() => void) | null = null; + let unsubscribe: (() => void) | undefined; const setupListener = async () => { try { - unlisten = await window.api.onProviderSwitched(async (data) => { - if (import.meta.env.DEV) { - console.log(t("console.providerSwitchReceived"), data); - } - - // 如果当前应用类型匹配,则重新加载数据 - if (data.appType === activeApp) { - await loadProviders(); - } - - // 若为 Claude,则同步插件配置 - if (data.appType === "claude") { - await syncClaudePlugin(data.providerId, true); - } - }); + unsubscribe = await window.api.onProviderSwitched( + async (event: ProviderSwitchEvent) => { + if (event.appType === activeApp) { + await refetch(); + } + }, + ); } catch (error) { - console.error(t("console.setupListenerFailed"), error); + console.error("[App] Failed to subscribe provider switch event", error); } }; setupListener(); - - // 清理监听器 return () => { - if (unlisten) { - unlisten(); - } + unsubscribe?.(); }; - }, [activeApp]); + }, [activeApp, refetch]); - const loadProviders = async () => { - const loadedProviders = await window.api.getProviders(activeApp); - const currentId = await window.api.getCurrentProvider(activeApp); - setProviders(loadedProviders); - setCurrentProviderId(currentId); - - // 如果供应商列表为空,尝试自动从 live 导入一条默认供应商 - if (Object.keys(loadedProviders).length === 0) { - await handleAutoImportDefault(); - } - }; - - // 生成唯一ID - const generateId = () => { - return crypto.randomUUID(); - }; - - const handleAddProvider = async (provider: Omit) => { - const newProvider: Provider = { - ...provider, - id: generateId(), - createdAt: Date.now(), // 添加创建时间戳 - }; - await window.api.addProvider(newProvider, activeApp); - await loadProviders(); - setIsAddModalOpen(false); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - }; - - const handleEditProvider = async (provider: Provider) => { - try { - await window.api.updateProvider(provider, activeApp); - await loadProviders(); - setEditingProviderId(null); - // 显示编辑成功提示 - showNotification(t("notifications.providerSaved"), "success", 2000); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - } catch (error) { - console.error(t("console.updateProviderFailed"), error); - setEditingProviderId(null); - const errorMessage = extractErrorMessage(error); - const message = errorMessage - ? t("notifications.saveFailed", { error: errorMessage }) - : t("notifications.saveFailedGeneric"); - showNotification(message, "error", errorMessage ? 6000 : 3000); - } - }; - - const handleDeleteProvider = async (id: string) => { - const provider = providers[id]; - setConfirmDialog({ - isOpen: true, - title: t("confirm.deleteProvider"), - message: t("confirm.deleteProviderMessage", { name: provider?.name }), - onConfirm: async () => { - await window.api.deleteProvider(id, activeApp); - await loadProviders(); - setConfirmDialog(null); - showNotification(t("notifications.providerDeleted"), "success"); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - }, - }); - }; - - // 同步 Claude 插件配置(按设置决定是否联动;开启时:非官方写入,官方移除) - const syncClaudePlugin = async (providerId: string, silent = false) => { - try { - const settings = await window.api.getSettings(); - if (!(settings as any)?.enableClaudePluginIntegration) { - // 未开启联动:不执行写入/移除 - return; - } - const provider = providers[providerId]; - if (!provider) return; - const isOfficial = provider.category === "official"; - await window.api.applyClaudePluginConfig({ official: isOfficial }); - if (!silent) { - showNotification( - isOfficial - ? t("notifications.removedFromClaudePlugin") - : t("notifications.appliedToClaudePlugin"), - "success", - 2000, - ); - } - } catch (error: any) { - console.error("同步 Claude 插件失败:", error); - if (!silent) { - const message = - error?.message || t("notifications.syncClaudePluginFailed"); - showNotification(message, "error", 5000); - } - } - }; - - const handleSwitchProvider = async (id: string) => { - try { - const success = await window.api.switchProvider(id, activeApp); - if (success) { - setCurrentProviderId(id); - // 显示重启提示 - const appName = t(`apps.${activeApp}`); - showNotification( - t("notifications.switchSuccess", { appName }), - "success", - 2000, - ); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - - if (activeApp === "claude") { - await syncClaudePlugin(id, true); - } + const handleNotify = useCallback( + (message: string, type: "success" | "error", duration?: number) => { + const options = duration ? { duration } : undefined; + if (type === "error") { + toast.error(message, options); } else { - showNotification(t("notifications.switchFailed"), "error"); + toast.success(message, options); } - } catch (error) { - const detail = extractErrorMessage(error); - const msg = detail - ? `${t("notifications.switchFailed")}: ${detail}` - : t("notifications.switchFailed"); - // 详细错误展示稍长时间,便于用户阅读 - showNotification(msg, "error", detail ? 6000 : 3000); - } - }; + }, + [], + ); - const handleImportSuccess = async () => { - await loadProviders(); - try { - await window.api.updateTrayMenu(); - } catch (error) { - console.error("[App] Failed to refresh tray menu after import", error); - } - }; - - // 自动从 live 导入一条默认供应商(仅首次初始化时) - const handleAutoImportDefault = async () => { - try { - const result = await window.api.importCurrentConfigAsDefault(activeApp); - - if (result.success) { - await loadProviders(); - showNotification(t("notifications.autoImported"), "success", 3000); - // 更新托盘菜单 - await window.api.updateTrayMenu(); + const handleOpenWebsite = useCallback( + async (url: string) => { + try { + await window.api.openExternal(url); + } catch (error) { + const detail = + extractErrorMessage(error) || + t("notifications.openLinkFailed", { + defaultValue: "链接打开失败", + }); + toast.error(detail); } - // 如果导入失败(比如没有现有配置),静默处理,不显示错误 - } catch (error) { - console.error(t("console.autoImportFailed"), error); - // 静默处理,不影响用户体验 + }, + [t], + ); + + const handleAddProvider = useCallback( + async (provider: Omit) => { + await addProviderMutation.mutateAsync(provider); + }, + [addProviderMutation], + ); + + const handleEditProvider = useCallback( + async (provider: Provider) => { + try { + await updateProviderMutation.mutateAsync(provider); + await providersApi.updateTrayMenu(); + setEditingProvider(null); + } catch { + // 错误提示由 mutation 统一处理 + } + }, + [updateProviderMutation], + ); + + const handleSyncClaudePlugin = useCallback( + async (provider: Provider) => { + if (activeApp !== "claude") return; + + try { + const settings = await window.api.getSettings(); + if (!settings?.enableClaudePluginIntegration) { + return; + } + + const isOfficial = provider.category === "official"; + await window.api.applyClaudePluginConfig({ official: isOfficial }); + + toast.success( + isOfficial + ? t("notifications.appliedToClaudePlugin", { + defaultValue: "已同步为官方配置", + }) + : t("notifications.removedFromClaudePlugin", { + defaultValue: "已移除 Claude 插件配置", + }), + { duration: 2200 }, + ); + } catch (error) { + const detail = + extractErrorMessage(error) || + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }); + toast.error(detail, { duration: 4200 }); + } + }, + [activeApp, t], + ); + + const handleSwitchProvider = useCallback( + async (provider: Provider) => { + try { + await switchProviderMutation.mutateAsync(provider.id); + await handleSyncClaudePlugin(provider); + } catch { + // 错误提示由 mutation 与同步函数处理 + } + }, + [switchProviderMutation, handleSyncClaudePlugin], + ); + + const handleRequestDelete = useCallback((provider: Provider) => { + setConfirmDelete(provider); + }, []); + + const handleConfirmDelete = useCallback(async () => { + if (!confirmDelete) return; + try { + await deleteProviderMutation.mutateAsync(confirmDelete.id); + } finally { + setConfirmDelete(null); } - }; + }, [confirmDelete, deleteProviderMutation]); + + const handleImportSuccess = useCallback(async () => { + await refetch(); + try { + await providersApi.updateTrayMenu(); + } catch (error) { + console.error("[App] Failed to refresh tray menu", error); + } + }, [refetch]); + + const handleSaveUsageScript = useCallback( + async (provider: Provider, script: UsageScript) => { + try { + const updatedProvider: Provider = { + ...provider, + meta: { + ...provider.meta, + usage_script: script, + }, + }; + + await providersApi.update(updatedProvider, activeApp); + await queryClient.invalidateQueries({ + queryKey: ["providers", activeApp], + }); + toast.success( + t("provider.usageSaved", { + defaultValue: "用量查询配置已保存", + }), + ); + } catch (error) { + const detail = + extractErrorMessage(error) || + t("provider.usageSaveFailed", { + defaultValue: "用量查询配置保存失败", + }); + toast.error(detail); + } + }, + [activeApp, queryClient, t], + ); return ( -
- {/* 顶部导航区域 - 固定高度 */} -
-
-
+
+
+
+
CC Switch - -
- - setIsSettingsOpen(true)} /> -
+ + + setIsSettingsOpen(true)} />
-
+
- - - - + +
- {/* 主内容区域 - 独立滚动 */} -
-
-
- {/* 通知组件 - 相对于视窗定位 */} - {notification && ( -
- {notification.message} -
- )} - - -
+
+
+ setIsAddOpen(true)} + />
- {isAddModalOpen && ( - + + { + if (!open) { + setEditingProvider(null); + } + }} + onSubmit={handleEditProvider} + /> + + {usageProvider && ( + setIsAddModalOpen(false)} + onClose={() => setUsageProvider(null)} + onSave={(script) => { + void handleSaveUsageScript(usageProvider, script); + }} + onNotify={handleNotify} /> )} - {editingProviderId && providers[editingProviderId] && ( - setEditingProviderId(null)} - /> - )} - - {confirmDialog && ( - setConfirmDialog(null)} - /> - )} + void handleConfirmDelete()} + onCancel={() => setConfirmDelete(null)} + /> {isSettingsOpen && ( setIsSettingsOpen(false)} onImportSuccess={handleImportSuccess} - onNotify={showNotification} + onNotify={handleNotify} /> )} @@ -401,7 +337,7 @@ function App() { setIsMcpOpen(false)} - onNotify={showNotification} + onNotify={handleNotify} /> )}
diff --git a/src/components/AddProviderModal.tsx b/src/components/AddProviderModal.tsx deleted file mode 100644 index 3b443ec..0000000 --- a/src/components/AddProviderModal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Provider } from "../types"; -import { AppType } from "../lib/tauri-api"; -import ProviderForm from "./ProviderForm"; - -interface AddProviderModalProps { - appType: AppType; - onAdd: (provider: Omit) => void; - onClose: () => void; -} - -const AddProviderModal: React.FC = ({ - appType, - onAdd, - onClose, -}) => { - const { t } = useTranslation(); - - const title = - appType === "claude" - ? t("provider.addClaudeProvider") - : t("provider.addCodexProvider"); - - return ( - - ); -}; - -export default AddProviderModal; diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 5c21cd5..ac46961 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -1,4 +1,4 @@ -import { AppType } from "../lib/tauri-api"; +import type { AppType } from "@/lib/api"; import { ClaudeIcon, CodexIcon } from "./BrandIcons"; interface AppSwitcherProps { diff --git a/src/components/EditProviderModal.tsx b/src/components/EditProviderModal.tsx deleted file mode 100644 index d2e4c03..0000000 --- a/src/components/EditProviderModal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Provider } from "../types"; -import { AppType } from "../lib/tauri-api"; -import ProviderForm from "./ProviderForm"; - -interface EditProviderModalProps { - appType: AppType; - provider: Provider; - onSave: (provider: Provider) => void; - onClose: () => void; -} - -const EditProviderModal: React.FC = ({ - appType, - provider, - onSave, - onClose, -}) => { - const { t } = useTranslation(); - const [effectiveProvider, setEffectiveProvider] = - useState(provider); - - // 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值(Claude/Codex 均适用) - useEffect(() => { - let mounted = true; - const maybeLoadLive = async () => { - try { - const currentId = await window.api.getCurrentProvider(appType); - if (currentId && currentId === provider.id) { - const live = await window.api.getLiveProviderSettings(appType); - if (!mounted) return; - setEffectiveProvider({ ...provider, settingsConfig: live }); - } else { - setEffectiveProvider(provider); - } - } catch (e) { - // 读取失败则回退到原 provider - setEffectiveProvider(provider); - } - }; - maybeLoadLive(); - return () => { - mounted = false; - }; - }, [appType, provider]); - - const handleSubmit = (data: Omit) => { - onSave({ - ...provider, - ...data, - }); - }; - - const title = - appType === "claude" - ? t("provider.editClaudeProvider") - : t("provider.editCodexProvider"); - - return ( - - ); -}; - -export default EditProviderModal; diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..36b04f6 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -0,0 +1,58 @@ +import { Moon, Sun } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTheme } from "@/components/theme-provider"; + +export function ModeToggle() { + const { theme, setTheme } = useTheme(); + const { t } = useTranslation(); + + const handleChange = (value: string) => { + if (value === "light" || value === "dark" || value === "system") { + setTheme(value); + } + }; + + return ( + + + + + + + {t("common.theme", { defaultValue: "主题" })} + + + + + {t("common.lightMode", { defaultValue: "浅色" })} + + + {t("common.darkMode", { defaultValue: "深色" })} + + + {t("common.systemMode", { defaultValue: "跟随系统" })} + + + + + ); +} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx new file mode 100644 index 0000000..b5d4e22 --- /dev/null +++ b/src/components/providers/AddProviderDialog.tsx @@ -0,0 +1,77 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { Provider } from "@/types"; +import type { AppType } from "@/lib/api"; +import { + ProviderForm, + type ProviderFormValues, +} from "@/components/providers/forms/ProviderForm"; + +interface AddProviderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + appType: AppType; + onSubmit: (provider: Omit) => Promise | void; +} + +export function AddProviderDialog({ + open, + onOpenChange, + appType, + onSubmit, +}: AddProviderDialogProps) { + const { t } = useTranslation(); + + const handleSubmit = useCallback( + async (values: ProviderFormValues) => { + const parsedConfig = JSON.parse(values.settingsConfig) as Record< + string, + unknown + >; + + const providerData: Omit = { + name: values.name.trim(), + websiteUrl: values.websiteUrl?.trim() || undefined, + settingsConfig: parsedConfig, + meta: {}, + }; + + await onSubmit(providerData); + onOpenChange(false); + }, + [onSubmit, onOpenChange], + ); + + const submitLabel = + appType === "claude" + ? t("provider.addClaudeProvider", { defaultValue: "添加 Claude 供应商" }) + : t("provider.addCodexProvider", { defaultValue: "添加 Codex 供应商" }); + + return ( + + + + {submitLabel} + + {t("provider.addDescription", { + defaultValue: "填写信息后即可在列表中快速切换供应商。", + })} + + + + onOpenChange(false)} + /> + + + ); +} diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx new file mode 100644 index 0000000..0b9edbd --- /dev/null +++ b/src/components/providers/EditProviderDialog.tsx @@ -0,0 +1,84 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { Provider } from "@/types"; +import { + ProviderForm, + type ProviderFormValues, +} from "@/components/providers/forms/ProviderForm"; + +interface EditProviderDialogProps { + open: boolean; + provider: Provider | null; + onOpenChange: (open: boolean) => void; + onSubmit: (provider: Provider) => Promise | void; +} + +export function EditProviderDialog({ + open, + provider, + onOpenChange, + onSubmit, +}: EditProviderDialogProps) { + const { t } = useTranslation(); + + const handleSubmit = useCallback( + async (values: ProviderFormValues) => { + if (!provider) return; + + const parsedConfig = JSON.parse(values.settingsConfig) as Record< + string, + unknown + >; + + const updatedProvider: Provider = { + ...provider, + name: values.name.trim(), + websiteUrl: values.websiteUrl?.trim() || undefined, + settingsConfig: parsedConfig, + }; + + await onSubmit(updatedProvider); + onOpenChange(false); + }, + [onSubmit, onOpenChange, provider], + ); + + if (!provider) { + return null; + } + + return ( + + + + + {t("provider.editProvider", { defaultValue: "编辑供应商" })} + + + {t("provider.editDescription", { + defaultValue: "更新配置后将立即应用到当前供应商。", + })} + + + + onOpenChange(false)} + initialData={{ + name: provider.name, + websiteUrl: provider.websiteUrl, + settingsConfig: provider.settingsConfig, + }} + /> + + + ); +} diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx new file mode 100644 index 0000000..9e78666 --- /dev/null +++ b/src/components/providers/ProviderActions.tsx @@ -0,0 +1,75 @@ +import { BarChart3, Check, Play, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface ProviderActionsProps { + isCurrent: boolean; + onSwitch: () => void; + onEdit: () => void; + onConfigureUsage: () => void; + onDelete: () => void; +} + +export function ProviderActions({ + isCurrent, + onSwitch, + onEdit, + onConfigureUsage, + onDelete, +}: ProviderActionsProps) { + const { t } = useTranslation(); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx new file mode 100644 index 0000000..6abacb0 --- /dev/null +++ b/src/components/providers/ProviderCard.tsx @@ -0,0 +1,155 @@ +import { useMemo } from "react"; +import { GripVertical, Link } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; +import type { Provider } from "@/types"; +import type { AppType } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { ProviderActions } from "@/components/providers/ProviderActions"; +import UsageFooter from "@/components/UsageFooter"; + +interface DragHandleProps { + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; + isDragging: boolean; +} + +interface ProviderCardProps { + provider: Provider; + isCurrent: boolean; + appType: AppType; + onSwitch: (provider: Provider) => void; + onEdit: (provider: Provider) => void; + onDelete: (provider: Provider) => void; + onConfigureUsage: (provider: Provider) => void; + onOpenWebsite: (url: string) => void; + dragHandleProps?: DragHandleProps; +} + +const extractApiUrl = (provider: Provider, fallbackText: string) => { + if (provider.websiteUrl) { + return provider.websiteUrl; + } + + const config = provider.settingsConfig; + + if (config && typeof config === "object") { + const envBase = (config as Record)?.env?.ANTHROPIC_BASE_URL; + if (typeof envBase === "string" && envBase.trim()) { + return envBase; + } + + const baseUrl = (config as Record)?.config; + + if (typeof baseUrl === "string" && baseUrl.includes("base_url")) { + const match = baseUrl.match(/base_url\s*=\s*['"]([^'"]+)['"]/); + if (match?.[1]) { + return match[1]; + } + } + } + + return fallbackText; +}; + +export function ProviderCard({ + provider, + isCurrent, + appType, + onSwitch, + onEdit, + onDelete, + onConfigureUsage, + onOpenWebsite, + dragHandleProps, +}: ProviderCardProps) { + const { t } = useTranslation(); + + const fallbackUrlText = t("provider.notConfigured", { + defaultValue: "未配置接口地址", + }); + + const displayUrl = useMemo(() => { + return extractApiUrl(provider, fallbackUrlText); + }, [provider, fallbackUrlText]); + + const usageEnabled = provider.meta?.usage_script?.enabled ?? false; + + const handleOpenWebsite = () => { + if (!displayUrl || displayUrl === fallbackUrlText) { + return; + } + onOpenWebsite(displayUrl); + }; + + return ( +
+
+
+ + +
+
+

+ {provider.name} +

+ {isCurrent && ( + + {t("provider.currentlyUsing", { defaultValue: "当前使用" })} + + )} +
+ + {displayUrl && ( + + )} +
+
+ + onSwitch(provider)} + onEdit={() => onEdit(provider)} + onConfigureUsage={() => onConfigureUsage(provider)} + onDelete={() => onDelete(provider)} + /> +
+ + +
+ ); +} diff --git a/src/components/providers/ProviderEmptyState.tsx b/src/components/providers/ProviderEmptyState.tsx new file mode 100644 index 0000000..dd335b7 --- /dev/null +++ b/src/components/providers/ProviderEmptyState.tsx @@ -0,0 +1,32 @@ +import { Users } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; + +interface ProviderEmptyStateProps { + onCreate?: () => void; +} + +export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) { + const { t } = useTranslation(); + + return ( +
+
+ +
+

+ {t("provider.noProviders", { defaultValue: "暂无供应商" })} +

+

+ {t("provider.noProvidersDescription", { + defaultValue: "开始添加一个供应商以快速完成切换。", + })} +

+ {onCreate && ( + + )} +
+ ); +} diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx new file mode 100644 index 0000000..a3d5e2c --- /dev/null +++ b/src/components/providers/ProviderList.tsx @@ -0,0 +1,153 @@ +import { CSS } from "@dnd-kit/utilities"; +import { + DndContext, + closestCenter, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import type { CSSProperties } from "react"; +import type { Provider } from "@/types"; +import type { AppType } from "@/lib/api"; +import { useDragSort } from "@/hooks/useDragSort"; +import { ProviderCard } from "@/components/providers/ProviderCard"; +import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; + +interface ProviderListProps { + providers: Record; + currentProviderId: string; + appType: AppType; + onSwitch: (provider: Provider) => void; + onEdit: (provider: Provider) => void; + onDelete: (provider: Provider) => void; + onConfigureUsage?: (provider: Provider) => void; + onOpenWebsite: (url: string) => void; + onCreate?: () => void; + isLoading?: boolean; +} + +export function ProviderList({ + providers, + currentProviderId, + appType, + onSwitch, + onEdit, + onDelete, + onConfigureUsage, + onOpenWebsite, + onCreate, + isLoading = false, +}: ProviderListProps) { + const { sortedProviders, sensors, handleDragEnd } = useDragSort( + providers, + appType, + ); + + if (isLoading) { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ); + } + + if (sortedProviders.length === 0) { + return ; + } + + return ( + + provider.id)} + strategy={verticalListSortingStrategy} + > +
+ {sortedProviders.map((provider) => ( + + ))} +
+
+
+ ); +} + +interface SortableProviderCardProps { + provider: Provider; + isCurrent: boolean; + appType: AppType; + onSwitch: (provider: Provider) => void; + onEdit: (provider: Provider) => void; + onDelete: (provider: Provider) => void; + onConfigureUsage?: (provider: Provider) => void; + onOpenWebsite: (url: string) => void; +} + +function SortableProviderCard({ + provider, + isCurrent, + appType, + onSwitch, + onEdit, + onDelete, + onConfigureUsage, + onOpenWebsite, +}: SortableProviderCardProps) { + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ id: provider.id }); + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ onConfigureUsage(item) + : () => undefined + } + onOpenWebsite={onOpenWebsite} + dragHandleProps={{ + attributes, + listeners, + isDragging, + }} + /> +
+ ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx new file mode 100644 index 0000000..222b13c --- /dev/null +++ b/src/components/providers/forms/ProviderForm.tsx @@ -0,0 +1,166 @@ +import { useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useTheme } from "@/components/theme-provider"; +import JsonEditor from "@/components/JsonEditor"; +import { + providerSchema, + type ProviderFormData, +} from "@/lib/schemas/provider"; + +interface ProviderFormProps { + submitLabel: string; + onSubmit: (values: ProviderFormData) => void; + onCancel: () => void; + initialData?: { + name?: string; + websiteUrl?: string; + settingsConfig?: Record; + }; +} + +const DEFAULT_CONFIG_PLACEHOLDER = `{ + "env": {}, + "config": {} +}`; + +export function ProviderForm({ + submitLabel, + onSubmit, + onCancel, + initialData, +}: ProviderFormProps) { + const { t } = useTranslation(); + const { theme } = useTheme(); + + const defaultValues: ProviderFormData = useMemo( + () => ({ + name: initialData?.name ?? "", + websiteUrl: initialData?.websiteUrl ?? "", + settingsConfig: initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig, null, 2) + : DEFAULT_CONFIG_PLACEHOLDER, + }), + [initialData], + ); + + const form = useForm({ + resolver: zodResolver(providerSchema), + defaultValues, + mode: "onSubmit", + }); + + useEffect(() => { + form.reset(defaultValues); + }, [defaultValues, form]); + + const isDarkMode = useMemo(() => { + if (theme === "dark") return true; + if (theme === "light") return false; + return typeof window !== "undefined" + ? window.document.documentElement.classList.contains("dark") + : false; + }, [theme]); + + const handleSubmit = (values: ProviderFormData) => { + onSubmit({ + ...values, + websiteUrl: values.websiteUrl?.trim() ?? "", + settingsConfig: values.settingsConfig.trim(), + }); + }; + + return ( +
+ + ( + + + {t("provider.name", { defaultValue: "供应商名称" })} + + + + + + + )} + /> + + ( + + + {t("provider.websiteUrl", { defaultValue: "官网链接" })} + + + + + + + )} + /> + + ( + + + {t("provider.configJson", { defaultValue: "配置 JSON" })} + + +
+ +
+
+ +
+ )} + /> + +
+ + +
+ + + ); +} + +export type ProviderFormValues = ProviderFormData; diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..b1a4507 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,120 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +type Theme = "light" | "dark" | "system"; + +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +interface ThemeContextValue { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const ThemeProviderContext = createContext( + undefined, +); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "cc-switch-theme", +}: ThemeProviderProps) { + const getInitialTheme = () => { + if (typeof window === "undefined") { + return defaultTheme; + } + + const stored = window.localStorage.getItem(storageKey) as Theme | null; + if (stored === "light" || stored === "dark" || stored === "system") { + return stored; + } + + return defaultTheme; + }; + + const [theme, setThemeState] = useState(getInitialTheme); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(storageKey, theme); + }, [theme, storageKey]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const root = window.document.documentElement; + root.classList.remove("light", "dark"); + + if (theme === "system") { + const isDark = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches; + root.classList.add(isDark ? "dark" : "light"); + return; + } + + root.classList.add(theme); + }, [theme]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + if (theme !== "system") { + return; + } + + const root = window.document.documentElement; + root.classList.toggle("dark", mediaQuery.matches); + root.classList.toggle("light", !mediaQuery.matches); + }; + + if (theme === "system") { + handleChange(); + } + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [theme]); + + const value = useMemo( + () => ({ + theme, + setTheme: (nextTheme: Theme) => { + setThemeState(nextTheme); + }, + }), + [theme], + ); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeProviderContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b4f462d --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,203 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ + + {children} + +)); +DropdownMenuRadioItem.displayName = + DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = + DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuRadioGroup, +}; diff --git a/src/hooks/useDragSort.ts b/src/hooks/useDragSort.ts new file mode 100644 index 0000000..2112980 --- /dev/null +++ b/src/hooks/useDragSort.ts @@ -0,0 +1,102 @@ +import { useCallback, useMemo } from "react"; +import { + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { Provider } from "@/types"; +import { providersApi, type AppType } from "@/lib/api"; + +export function useDragSort( + providers: Record, + appType: AppType, +) { + const queryClient = useQueryClient(); + const { t, i18n } = useTranslation(); + + const sortedProviders = useMemo(() => { + const locale = i18n.language === "zh" ? "zh-CN" : "en-US"; + return Object.values(providers).sort((a, b) => { + if (a.sortIndex !== undefined && b.sortIndex !== undefined) { + return a.sortIndex - b.sortIndex; + } + if (a.sortIndex !== undefined) return -1; + if (b.sortIndex !== undefined) return 1; + + const timeA = a.createdAt ?? 0; + const timeB = b.createdAt ?? 0; + if (timeA && timeB && timeA !== timeB) { + return timeA - timeB; + } + + return a.name.localeCompare(b.name, locale); + }); + }, [providers, i18n.language]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const oldIndex = sortedProviders.findIndex( + (provider) => provider.id === active.id, + ); + const newIndex = sortedProviders.findIndex( + (provider) => provider.id === over.id, + ); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const reordered = arrayMove(sortedProviders, oldIndex, newIndex); + const updates = reordered.map((provider, index) => ({ + id: provider.id, + sortIndex: index, + })); + + try { + await providersApi.updateSortOrder(updates, appType); + await queryClient.invalidateQueries({ + queryKey: ["providers", appType], + }); + toast.success( + t("provider.sortUpdated", { + defaultValue: "排序已更新", + }), + ); + } catch (error) { + console.error("Failed to update provider sort order", error); + toast.error( + t("provider.sortUpdateFailed", { + defaultValue: "排序更新失败", + }), + ); + } + }, + [sortedProviders, appType, queryClient, t], + ); + + return { + sortedProviders, + sensors, + handleDragEnd, + }; +} diff --git a/src/main.tsx b/src/main.tsx index f2d9adb..7b055a1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,10 @@ import "./index.css"; import "./lib/tauri-api"; // 导入国际化配置 import "./i18n"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "@/components/theme-provider"; +import { queryClient } from "@/lib/query"; +import { Toaster } from "@/components/ui/sonner"; // 根据平台添加 body class,便于平台特定样式 try { @@ -22,8 +26,13 @@ try { ReactDOM.createRoot(document.getElementById("root")!).render( - - - + + + + + + + + , );