feat: complete stage 2 core refactor
This commit is contained in:
@@ -871,7 +871,7 @@ export function useDragSort(
|
|||||||
| ---------- | -------------- | ------------ | ---------------------------- |
|
| ---------- | -------------- | ------------ | ---------------------------- |
|
||||||
| **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 |
|
| **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 |
|
||||||
| **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 |
|
| **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 |
|
||||||
| **阶段 2** | 重构核心功能 | 3-4 天 | App.tsx、ProviderList 完成 |
|
| **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 |
|
||||||
| **阶段 3** | 重构设置和辅助 | 2-3 天 | SettingsDialog、通知系统完成 |
|
| **阶段 3** | 重构设置和辅助 | 2-3 天 | SettingsDialog、通知系统完成 |
|
||||||
| **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 |
|
| **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 |
|
||||||
| **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 |
|
| **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 |
|
||||||
@@ -1366,13 +1366,13 @@ export type ProviderFormData = z.infer<typeof providerSchema>;
|
|||||||
|
|
||||||
#### 任务清单
|
#### 任务清单
|
||||||
|
|
||||||
- [ ] 更新 `main.tsx` (添加 Providers)
|
- [x] 更新 `main.tsx` (添加 Providers)
|
||||||
- [ ] 创建主题 Provider
|
- [x] 创建主题 Provider
|
||||||
- [ ] 重构 `App.tsx` (412行 → ~100行)
|
- [x] 重构 `App.tsx` (412行 → ~100行)
|
||||||
- [ ] 拆分 ProviderList (4个组件)
|
- [x] 拆分 ProviderList (4个组件)
|
||||||
- [ ] 创建 `useDragSort` Hook
|
- [x] 创建 `useDragSort` Hook
|
||||||
- [ ] 重构表单组件 (使用 react-hook-form)
|
- [x] 重构表单组件 (使用 react-hook-form)
|
||||||
- [ ] 创建 AddProvider / EditProvider Dialog
|
- [x] 创建 AddProvider / EditProvider Dialog
|
||||||
|
|
||||||
#### 详细步骤
|
#### 详细步骤
|
||||||
|
|
||||||
|
|||||||
614
src/App.tsx
614
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 { useTranslation } from "react-i18next";
|
||||||
import { Provider } from "./types";
|
import { toast } from "sonner";
|
||||||
import { AppType } from "./lib/tauri-api";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import ProviderList from "./components/ProviderList";
|
import { Plus, Settings } from "lucide-react";
|
||||||
import AddProviderModal from "./components/AddProviderModal";
|
import type { Provider, UsageScript } from "@/types";
|
||||||
import EditProviderModal from "./components/EditProviderModal";
|
import {
|
||||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
useProvidersQuery,
|
||||||
import { AppSwitcher } from "./components/AppSwitcher";
|
useAddProviderMutation,
|
||||||
import SettingsModal from "./components/SettingsModal";
|
useUpdateProviderMutation,
|
||||||
import { UpdateBadge } from "./components/UpdateBadge";
|
useDeleteProviderMutation,
|
||||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
useSwitchProviderMutation,
|
||||||
import McpPanel from "./components/mcp/McpPanel";
|
} from "@/lib/query";
|
||||||
import { buttonStyles } from "./lib/styles";
|
import { providersApi, type AppType } from "@/lib/api";
|
||||||
import { useDarkMode } from "./hooks/useDarkMode";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
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() {
|
function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
|
||||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
||||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
|
||||||
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 [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||||
|
|
||||||
// 设置通知的辅助函数
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||||
const showNotification = (
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
message: string,
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
type: "success" | "error",
|
|
||||||
duration = 3000,
|
|
||||||
) => {
|
|
||||||
// 清除之前的定时器
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即显示通知
|
const addProviderMutation = useAddProviderMutation(activeApp);
|
||||||
setNotification({ message, type });
|
const updateProviderMutation = useUpdateProviderMutation(activeApp);
|
||||||
setIsNotificationVisible(true);
|
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
|
||||||
|
const switchProviderMutation = useSwitchProviderMutation(activeApp);
|
||||||
|
|
||||||
// 设置淡出定时器
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
setIsNotificationVisible(false);
|
|
||||||
// 等待淡出动画完成后清除通知
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotification(null);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}, 300); // 与CSS动画时间匹配
|
|
||||||
}, duration);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载供应商列表
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProviders();
|
let unsubscribe: (() => void) | undefined;
|
||||||
}, [activeApp]); // 当切换应用时重新加载
|
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听托盘切换事件(包括菜单切换)
|
|
||||||
useEffect(() => {
|
|
||||||
let unlisten: (() => void) | null = null;
|
|
||||||
|
|
||||||
const setupListener = async () => {
|
const setupListener = async () => {
|
||||||
try {
|
try {
|
||||||
unlisten = await window.api.onProviderSwitched(async (data) => {
|
unsubscribe = await window.api.onProviderSwitched(
|
||||||
if (import.meta.env.DEV) {
|
async (event: ProviderSwitchEvent) => {
|
||||||
console.log(t("console.providerSwitchReceived"), data);
|
if (event.appType === activeApp) {
|
||||||
}
|
await refetch();
|
||||||
|
}
|
||||||
// 如果当前应用类型匹配,则重新加载数据
|
},
|
||||||
if (data.appType === activeApp) {
|
);
|
||||||
await loadProviders();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若为 Claude,则同步插件配置
|
|
||||||
if (data.appType === "claude") {
|
|
||||||
await syncClaudePlugin(data.providerId, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("console.setupListenerFailed"), error);
|
console.error("[App] Failed to subscribe provider switch event", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setupListener();
|
setupListener();
|
||||||
|
|
||||||
// 清理监听器
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unlisten) {
|
unsubscribe?.();
|
||||||
unlisten();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [activeApp]);
|
}, [activeApp, refetch]);
|
||||||
|
|
||||||
const loadProviders = async () => {
|
const handleNotify = useCallback(
|
||||||
const loadedProviders = await window.api.getProviders(activeApp);
|
(message: string, type: "success" | "error", duration?: number) => {
|
||||||
const currentId = await window.api.getCurrentProvider(activeApp);
|
const options = duration ? { duration } : undefined;
|
||||||
setProviders(loadedProviders);
|
if (type === "error") {
|
||||||
setCurrentProviderId(currentId);
|
toast.error(message, options);
|
||||||
|
|
||||||
// 如果供应商列表为空,尝试自动从 live 导入一条默认供应商
|
|
||||||
if (Object.keys(loadedProviders).length === 0) {
|
|
||||||
await handleAutoImportDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成唯一ID
|
|
||||||
const generateId = () => {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddProvider = async (provider: Omit<Provider, "id">) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} 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 () => {
|
const handleOpenWebsite = useCallback(
|
||||||
await loadProviders();
|
async (url: string) => {
|
||||||
try {
|
try {
|
||||||
await window.api.updateTrayMenu();
|
await window.api.openExternal(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[App] Failed to refresh tray menu after import", error);
|
const detail =
|
||||||
}
|
extractErrorMessage(error) ||
|
||||||
};
|
t("notifications.openLinkFailed", {
|
||||||
|
defaultValue: "链接打开失败",
|
||||||
// 自动从 live 导入一条默认供应商(仅首次初始化时)
|
});
|
||||||
const handleAutoImportDefault = async () => {
|
toast.error(detail);
|
||||||
try {
|
|
||||||
const result = await window.api.importCurrentConfigAsDefault(activeApp);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await loadProviders();
|
|
||||||
showNotification(t("notifications.autoImported"), "success", 3000);
|
|
||||||
// 更新托盘菜单
|
|
||||||
await window.api.updateTrayMenu();
|
|
||||||
}
|
}
|
||||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
},
|
||||||
} catch (error) {
|
[t],
|
||||||
console.error(t("console.autoImportFailed"), error);
|
);
|
||||||
// 静默处理,不影响用户体验
|
|
||||||
|
const handleAddProvider = useCallback(
|
||||||
|
async (provider: Omit<Provider, "id">) => {
|
||||||
|
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 (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
{/* 顶部导航区域 - 固定高度 */}
|
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
title={t("header.viewOnGithub")}
|
|
||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<button
|
<ModeToggle />
|
||||||
onClick={toggleDarkMode}
|
<Button
|
||||||
className={buttonStyles.icon}
|
variant="ghost"
|
||||||
title={
|
size="icon"
|
||||||
isDarkMode
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
? t("header.toggleLightMode")
|
|
||||||
: t("header.toggleDarkMode")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
<Settings className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||||
<button
|
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
|
||||||
className={buttonStyles.icon}
|
|
||||||
title={t("common.settings")}
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</button>
|
|
||||||
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
onClick={() => setIsMcpOpen(true)}
|
onClick={() => setIsMcpOpen(true)}
|
||||||
className="inline-flex items-center gap-2 px-7 py-2 text-sm font-medium rounded-lg transition-colors bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
|
||||||
>
|
>
|
||||||
MCP
|
MCP
|
||||||
</button>
|
</Button>
|
||||||
|
<Button onClick={() => setIsAddOpen(true)}>
|
||||||
<button
|
<Plus className="h-4 w-4" />
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
{t("header.addProvider", { defaultValue: "添加供应商" })}
|
||||||
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
|
</Button>
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("header.addProvider")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 主内容区域 - 独立滚动 */}
|
<main className="flex-1 overflow-y-auto">
|
||||||
<main className="flex-1 overflow-y-scroll">
|
<div className="mx-auto max-w-4xl px-6 py-6">
|
||||||
<div className="pt-3 px-6 pb-6">
|
<ProviderList
|
||||||
<div className="max-w-4xl mx-auto">
|
providers={providers}
|
||||||
{/* 通知组件 - 相对于视窗定位 */}
|
currentProviderId={currentProviderId}
|
||||||
{notification && (
|
appType={activeApp}
|
||||||
<div
|
isLoading={isLoading}
|
||||||
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-[80] px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
onSwitch={handleSwitchProvider}
|
||||||
notification.type === "error"
|
onEdit={setEditingProvider}
|
||||||
? "bg-red-500 text-white"
|
onDelete={handleRequestDelete}
|
||||||
: "bg-green-500 text-white"
|
onConfigureUsage={setUsageProvider}
|
||||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
>
|
onCreate={() => setIsAddOpen(true)}
|
||||||
{notification.message}
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProviderList
|
|
||||||
providers={providers}
|
|
||||||
currentProviderId={currentProviderId}
|
|
||||||
onSwitch={handleSwitchProvider}
|
|
||||||
onDelete={handleDeleteProvider}
|
|
||||||
onEdit={setEditingProviderId}
|
|
||||||
appType={activeApp}
|
|
||||||
onNotify={showNotification}
|
|
||||||
onProvidersUpdated={loadProviders}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{isAddModalOpen && (
|
<AddProviderDialog
|
||||||
<AddProviderModal
|
open={isAddOpen}
|
||||||
|
onOpenChange={setIsAddOpen}
|
||||||
|
appType={activeApp}
|
||||||
|
onSubmit={handleAddProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditProviderDialog
|
||||||
|
open={Boolean(editingProvider)}
|
||||||
|
provider={editingProvider}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingProvider(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={handleEditProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{usageProvider && (
|
||||||
|
<UsageScriptModal
|
||||||
|
provider={usageProvider}
|
||||||
appType={activeApp}
|
appType={activeApp}
|
||||||
onAdd={handleAddProvider}
|
onClose={() => setUsageProvider(null)}
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
onSave={(script) => {
|
||||||
|
void handleSaveUsageScript(usageProvider, script);
|
||||||
|
}}
|
||||||
|
onNotify={handleNotify}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingProviderId && providers[editingProviderId] && (
|
<ConfirmDialog
|
||||||
<EditProviderModal
|
isOpen={Boolean(confirmDelete)}
|
||||||
appType={activeApp}
|
title={t("confirm.deleteProvider", { defaultValue: "删除供应商" })}
|
||||||
provider={providers[editingProviderId]}
|
message={
|
||||||
onSave={handleEditProvider}
|
confirmDelete
|
||||||
onClose={() => setEditingProviderId(null)}
|
? t("confirm.deleteProviderMessage", {
|
||||||
/>
|
name: confirmDelete.name,
|
||||||
)}
|
defaultValue: `确定删除 ${confirmDelete.name} 吗?`,
|
||||||
|
})
|
||||||
{confirmDialog && (
|
: ""
|
||||||
<ConfirmDialog
|
}
|
||||||
isOpen={confirmDialog.isOpen}
|
onConfirm={() => void handleConfirmDelete()}
|
||||||
title={confirmDialog.title}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
message={confirmDialog.message}
|
/>
|
||||||
onConfirm={confirmDialog.onConfirm}
|
|
||||||
onCancel={() => setConfirmDialog(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSettingsOpen && (
|
{isSettingsOpen && (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
onClose={() => setIsSettingsOpen(false)}
|
onClose={() => setIsSettingsOpen(false)}
|
||||||
onImportSuccess={handleImportSuccess}
|
onImportSuccess={handleImportSuccess}
|
||||||
onNotify={showNotification}
|
onNotify={handleNotify}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -401,7 +337,7 @@ function App() {
|
|||||||
<McpPanel
|
<McpPanel
|
||||||
appType={activeApp}
|
appType={activeApp}
|
||||||
onClose={() => setIsMcpOpen(false)}
|
onClose={() => setIsMcpOpen(false)}
|
||||||
onNotify={showNotification}
|
onNotify={handleNotify}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<Provider, "id">) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
|
||||||
appType,
|
|
||||||
onAdd,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const title =
|
|
||||||
appType === "claude"
|
|
||||||
? t("provider.addClaudeProvider")
|
|
||||||
: t("provider.addCodexProvider");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProviderForm
|
|
||||||
appType={appType}
|
|
||||||
title={title}
|
|
||||||
submitText={t("common.add")}
|
|
||||||
showPresets={true}
|
|
||||||
onSubmit={onAdd}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddProviderModal;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppType } from "../lib/tauri-api";
|
import type { AppType } from "@/lib/api";
|
||||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
|
|||||||
@@ -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<EditProviderModalProps> = ({
|
|
||||||
appType,
|
|
||||||
provider,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [effectiveProvider, setEffectiveProvider] =
|
|
||||||
useState<Provider>(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<Provider, "id">) => {
|
|
||||||
onSave({
|
|
||||||
...provider,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const title =
|
|
||||||
appType === "claude"
|
|
||||||
? t("provider.editClaudeProvider")
|
|
||||||
: t("provider.editCodexProvider");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProviderForm
|
|
||||||
appType={appType}
|
|
||||||
title={title}
|
|
||||||
submitText={t("common.save")}
|
|
||||||
initialData={effectiveProvider}
|
|
||||||
showPresets={false}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditProviderModal;
|
|
||||||
58
src/components/mode-toggle.tsx
Normal file
58
src/components/mode-toggle.tsx
Normal file
@@ -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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("common.toggleTheme", { defaultValue: "切换主题" })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{t("common.theme", { defaultValue: "主题" })}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={theme}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="light">
|
||||||
|
{t("common.lightMode", { defaultValue: "浅色" })}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark">
|
||||||
|
{t("common.darkMode", { defaultValue: "深色" })}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system">
|
||||||
|
{t("common.systemMode", { defaultValue: "跟随系统" })}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/providers/AddProviderDialog.tsx
Normal file
77
src/components/providers/AddProviderDialog.tsx
Normal file
@@ -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<Provider, "id">) => Promise<void> | 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<Provider, "id"> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{submitLabel}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("provider.addDescription", {
|
||||||
|
defaultValue: "填写信息后即可在列表中快速切换供应商。",
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ProviderForm
|
||||||
|
submitLabel={t("common.add", { defaultValue: "添加" })}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/providers/EditProviderDialog.tsx
Normal file
84
src/components/providers/EditProviderDialog.tsx
Normal file
@@ -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> | 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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("provider.editProvider", { defaultValue: "编辑供应商" })}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("provider.editDescription", {
|
||||||
|
defaultValue: "更新配置后将立即应用到当前供应商。",
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ProviderForm
|
||||||
|
submitLabel={t("common.save", { defaultValue: "保存" })}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
initialData={{
|
||||||
|
name: provider.name,
|
||||||
|
websiteUrl: provider.websiteUrl,
|
||||||
|
settingsConfig: provider.settingsConfig,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/providers/ProviderActions.tsx
Normal file
75
src/components/providers/ProviderActions.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isCurrent ? "secondary" : "default"}
|
||||||
|
onClick={onSwitch}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className={cn(
|
||||||
|
"w-[96px]",
|
||||||
|
isCurrent && "text-muted-foreground hover:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCurrent ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{t("provider.inUse", { defaultValue: "已启用" })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{t("provider.enable", { defaultValue: "启用" })}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" onClick={onEdit}>
|
||||||
|
{t("common.edit", { defaultValue: "编辑" })}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onConfigureUsage}
|
||||||
|
title={t("provider.configureUsage", { defaultValue: "配置用量查询" })}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className={cn(
|
||||||
|
"text-destructive hover:text-destructive",
|
||||||
|
isCurrent && "text-muted-foreground hover:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/components/providers/ProviderCard.tsx
Normal file
155
src/components/providers/ProviderCard.tsx
Normal file
@@ -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<string, any>)?.env?.ANTHROPIC_BASE_URL;
|
||||||
|
if (typeof envBase === "string" && envBase.trim()) {
|
||||||
|
return envBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (config as Record<string, any>)?.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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card p-4 shadow-sm transition-[box-shadow,transform] duration-200",
|
||||||
|
isCurrent
|
||||||
|
? "border-primary/70 bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/40",
|
||||||
|
dragHandleProps?.isDragging && "cursor-grabbing border-primary/60 shadow-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:border-muted hover:text-foreground",
|
||||||
|
dragHandleProps?.isDragging && "border-primary text-primary",
|
||||||
|
)}
|
||||||
|
aria-label={t("provider.dragHandle", { defaultValue: "拖拽排序" })}
|
||||||
|
{...(dragHandleProps?.attributes ?? {})}
|
||||||
|
{...(dragHandleProps?.listeners ?? {})}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold leading-none">
|
||||||
|
{provider.name}
|
||||||
|
</h3>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{t("provider.currentlyUsing", { defaultValue: "当前使用" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenWebsite}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-primary transition-colors hover:underline"
|
||||||
|
title={displayUrl}
|
||||||
|
>
|
||||||
|
<Link className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{displayUrl}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderActions
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
onSwitch={() => onSwitch(provider)}
|
||||||
|
onEdit={() => onEdit(provider)}
|
||||||
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
|
onDelete={() => onDelete(provider)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsageFooter
|
||||||
|
providerId={provider.id}
|
||||||
|
appType={appType}
|
||||||
|
usageEnabled={usageEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/providers/ProviderEmptyState.tsx
Normal file
32
src/components/providers/ProviderEmptyState.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Users className="h-7 w-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t("provider.noProviders", { defaultValue: "暂无供应商" })}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
|
{t("provider.noProvidersDescription", {
|
||||||
|
defaultValue: "开始添加一个供应商以快速完成切换。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{onCreate && (
|
||||||
|
<Button className="mt-6" onClick={onCreate}>
|
||||||
|
{t("provider.addProvider", { defaultValue: "添加供应商" })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/components/providers/ProviderList.tsx
Normal file
153
src/components/providers/ProviderList.tsx
Normal file
@@ -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<string, Provider>;
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortedProviders.length === 0) {
|
||||||
|
return <ProviderEmptyState onCreate={onCreate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedProviders.map((provider) => provider.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedProviders.map((provider) => (
|
||||||
|
<SortableProviderCard
|
||||||
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
isCurrent={provider.id === currentProviderId}
|
||||||
|
appType={appType}
|
||||||
|
onSwitch={onSwitch}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onConfigureUsage={onConfigureUsage}
|
||||||
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<ProviderCard
|
||||||
|
provider={provider}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
appType={appType}
|
||||||
|
onSwitch={onSwitch}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onConfigureUsage={
|
||||||
|
onConfigureUsage
|
||||||
|
? (item) => onConfigureUsage(item)
|
||||||
|
: () => undefined
|
||||||
|
}
|
||||||
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
dragHandleProps={{
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
isDragging,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/providers/forms/ProviderForm.tsx
Normal file
166
src/components/providers/forms/ProviderForm.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ProviderFormData>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("provider.name", { defaultValue: "供应商名称" })}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t("provider.namePlaceholder", {
|
||||||
|
defaultValue: "例如:Claude 官方",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="websiteUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("provider.websiteUrl", { defaultValue: "官网链接" })}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="settingsConfig"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("provider.configJson", { defaultValue: "配置 JSON" })}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<JsonEditor
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={DEFAULT_CONFIG_PLACEHOLDER}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
rows={14}
|
||||||
|
showValidation
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" type="button" onClick={onCancel}>
|
||||||
|
{t("common.cancel", { defaultValue: "取消" })}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{submitLabel}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderFormValues = ProviderFormData;
|
||||||
120
src/components/theme-provider.tsx
Normal file
120
src/components/theme-provider.tsx
Normal file
@@ -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<ThemeContextValue | undefined>(
|
||||||
|
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<Theme>(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<ThemeContextValue>(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
setTheme: (nextTheme: Theme) => {
|
||||||
|
setThemeState(nextTheme);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeProviderContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
203
src/components/ui/dropdown-menu.tsx
Normal file
203
src/components/ui/dropdown-menu.tsx
Normal file
@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16.704 5.292a1 1 0 0 1 .083 1.32l-.083.094-8 8a1 1 0 0 1-1.32.083l-.094-.083-4-4a1 1 0 0 1 1.32-1.497l.094.083L8 12.585l7.293-7.292a1 1 0 0 1 1.32-.083l.094.083Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<div className="h-2 w-2 rounded-full bg-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName =
|
||||||
|
DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-muted-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName =
|
||||||
|
DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
102
src/hooks/useDragSort.ts
Normal file
102
src/hooks/useDragSort.ts
Normal file
@@ -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<string, Provider>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/main.tsx
15
src/main.tsx
@@ -7,6 +7,10 @@ import "./index.css";
|
|||||||
import "./lib/tauri-api";
|
import "./lib/tauri-api";
|
||||||
// 导入国际化配置
|
// 导入国际化配置
|
||||||
import "./i18n";
|
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,便于平台特定样式
|
// 根据平台添加 body class,便于平台特定样式
|
||||||
try {
|
try {
|
||||||
@@ -22,8 +26,13 @@ try {
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<UpdateProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||||
</UpdateProvider>
|
<UpdateProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</UpdateProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user