mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
8fe5c1041a
* feat: add Universal Provider feature - Add Universal Provider data structures and type definitions - Implement backend CRUD operations and sync functionality - Add frontend UI components (UniversalProviderPanel, Card, FormModal) - Add NewAPI icon and preset configuration - Support cross-app (Claude/Codex/Gemini) configuration sync - Add website URL field for providers - Implement real-time refresh via event notifications - Add i18n support (Chinese/English/Japanese) * feat: integrate universal provider presets into add provider dialog - Add universal provider presets (NewAPI, Custom Gateway) to preset selector - Show universal presets with Layers icon badge in preset selector - Open UniversalProviderFormModal when universal preset is clicked - Pass initialPreset to auto-fill form when opened from add dialog - Add i18n keys for addSuccess/addFailed messages - Keep separate universal provider panel for management * refactor: move universal provider management to add dialog - Remove Layers button from main navigation header - Add 'Manage' button next to universal provider presets - Open UniversalProviderPanel from within add provider dialog - Add i18n keys for 'manage' in all locales * style: display universal provider presets on separate line - Move universal provider section to a new row with border separator - Add label '统一供应商:' to clarify the section * style: unify universal provider label style with preset label - Use FormLabel component for consistent styling - Add background to 'Manage' button matching preset buttons - Update icon size and button padding for consistency * feat: add sync functionality and JSON preview for Universal Provider * fix: add missing in_failover_queue field to Provider structs After rebasing to main, the Provider struct gained a new `in_failover_queue` field. This fix adds the missing field to the three to_*_provider() methods in UniversalProvider. * refactor: redesign AddProviderDialog with tab-based layout - Add tabs to separate app-specific providers and universal providers - Move "Add Universal Provider" button from panel header to footer - Remove unused handleAdd callback and clean up imports - Update emptyHint i18n text to reference the footer button * fix: append /v1 suffix to Codex base_url in Universal Provider Codex uses OpenAI-compatible API which requires the /v1 endpoint suffix. The Universal Provider now automatically appends /v1 to base_url when generating Codex provider config if not already present. - Handle trailing slashes to avoid double slashes - Apply fix to both backend (to_codex_provider) and frontend preview * feat: auto-sync universal provider to apps on creation Previously, users had to manually click sync after adding a universal provider. Now it automatically syncs to Claude/Codex/Gemini on creation, providing a smoother user experience. --------- Co-authored-by: Jason <farion1231@gmail.com>
289 lines
9.0 KiB
TypeScript
289 lines
9.0 KiB
TypeScript
import { useState, useCallback, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Layers } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
import { UniversalProviderCard } from "./UniversalProviderCard";
|
|
import { UniversalProviderFormModal } from "./UniversalProviderFormModal";
|
|
import { universalProvidersApi } from "@/lib/api";
|
|
import type { UniversalProvider, UniversalProvidersMap } from "@/types";
|
|
|
|
export function UniversalProviderPanel() {
|
|
const { t } = useTranslation();
|
|
|
|
// 状态
|
|
const [providers, setProviders] = useState<UniversalProvidersMap>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
const [editingProvider, setEditingProvider] =
|
|
useState<UniversalProvider | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
open: boolean;
|
|
id: string;
|
|
name: string;
|
|
}>({ open: false, id: "", name: "" });
|
|
const [syncConfirm, setSyncConfirm] = useState<{
|
|
open: boolean;
|
|
id: string;
|
|
name: string;
|
|
}>({ open: false, id: "", name: "" });
|
|
|
|
// 加载数据
|
|
const loadProviders = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await universalProvidersApi.getAll();
|
|
setProviders(data);
|
|
} catch (error) {
|
|
console.error("Failed to load universal providers:", error);
|
|
toast.error(
|
|
t("universalProvider.loadError", {
|
|
defaultValue: "加载统一供应商失败",
|
|
}),
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
loadProviders();
|
|
}, [loadProviders]);
|
|
|
|
// 添加/编辑供应商
|
|
const handleSave = useCallback(
|
|
async (provider: UniversalProvider) => {
|
|
try {
|
|
await universalProvidersApi.upsert(provider);
|
|
|
|
// 新建模式下自动同步到各应用
|
|
if (!editingProvider) {
|
|
await universalProvidersApi.sync(provider.id);
|
|
}
|
|
|
|
toast.success(
|
|
editingProvider
|
|
? t("universalProvider.updated", {
|
|
defaultValue: "统一供应商已更新",
|
|
})
|
|
: t("universalProvider.addedAndSynced", {
|
|
defaultValue: "统一供应商已添加并同步",
|
|
}),
|
|
);
|
|
loadProviders();
|
|
setEditingProvider(null);
|
|
} catch (error) {
|
|
console.error("Failed to save universal provider:", error);
|
|
toast.error(
|
|
t("universalProvider.saveError", {
|
|
defaultValue: "保存统一供应商失败",
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
[editingProvider, loadProviders, t],
|
|
);
|
|
|
|
// 保存并同步供应商
|
|
const handleSaveAndSync = useCallback(
|
|
async (provider: UniversalProvider) => {
|
|
try {
|
|
await universalProvidersApi.upsert(provider);
|
|
await universalProvidersApi.sync(provider.id);
|
|
toast.success(
|
|
t("universalProvider.savedAndSynced", {
|
|
defaultValue: "已保存并同步到所有应用",
|
|
}),
|
|
);
|
|
loadProviders();
|
|
setEditingProvider(null);
|
|
} catch (error) {
|
|
console.error("Failed to save and sync universal provider:", error);
|
|
toast.error(
|
|
t("universalProvider.saveAndSyncError", {
|
|
defaultValue: "保存并同步失败",
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
[loadProviders, t],
|
|
);
|
|
|
|
// 删除供应商
|
|
const handleDelete = useCallback(async () => {
|
|
if (!deleteConfirm.id) return;
|
|
|
|
try {
|
|
await universalProvidersApi.delete(deleteConfirm.id);
|
|
toast.success(
|
|
t("universalProvider.deleted", { defaultValue: "统一供应商已删除" }),
|
|
);
|
|
loadProviders();
|
|
} catch (error) {
|
|
console.error("Failed to delete universal provider:", error);
|
|
toast.error(
|
|
t("universalProvider.deleteError", {
|
|
defaultValue: "删除统一供应商失败",
|
|
}),
|
|
);
|
|
} finally {
|
|
setDeleteConfirm({ open: false, id: "", name: "" });
|
|
}
|
|
}, [deleteConfirm.id, loadProviders, t]);
|
|
|
|
// 同步供应商
|
|
const handleSync = useCallback(async () => {
|
|
if (!syncConfirm.id) return;
|
|
|
|
try {
|
|
await universalProvidersApi.sync(syncConfirm.id);
|
|
toast.success(
|
|
t("universalProvider.synced", { defaultValue: "已同步到所有应用" }),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to sync universal provider:", error);
|
|
toast.error(
|
|
t("universalProvider.syncError", {
|
|
defaultValue: "同步统一供应商失败",
|
|
}),
|
|
);
|
|
} finally {
|
|
setSyncConfirm({ open: false, id: "", name: "" });
|
|
}
|
|
}, [syncConfirm.id, t]);
|
|
|
|
// 打开同步确认
|
|
const handleSyncClick = useCallback(
|
|
(id: string) => {
|
|
const provider = providers[id];
|
|
setSyncConfirm({
|
|
open: true,
|
|
id,
|
|
name: provider?.name || id,
|
|
});
|
|
},
|
|
[providers],
|
|
);
|
|
|
|
// 打开编辑
|
|
const handleEdit = useCallback((provider: UniversalProvider) => {
|
|
setEditingProvider(provider);
|
|
setIsFormOpen(true);
|
|
}, []);
|
|
|
|
// 打开删除确认
|
|
const handleDeleteClick = useCallback(
|
|
(id: string) => {
|
|
const provider = providers[id];
|
|
setDeleteConfirm({
|
|
open: true,
|
|
id,
|
|
name: provider?.name || id,
|
|
});
|
|
},
|
|
[providers],
|
|
);
|
|
|
|
const providerList = Object.values(providers);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 头部 */}
|
|
<div className="flex items-center gap-2">
|
|
<Layers className="h-5 w-5 text-primary" />
|
|
<h2 className="text-lg font-semibold">
|
|
{t("universalProvider.title", { defaultValue: "统一供应商" })}
|
|
</h2>
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
{providerList.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 描述 */}
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("universalProvider.description", {
|
|
defaultValue:
|
|
"统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
|
|
})}
|
|
</p>
|
|
|
|
{/* 供应商列表 */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
) : providerList.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center">
|
|
<Layers className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("universalProvider.empty", {
|
|
defaultValue: "还没有统一供应商",
|
|
})}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
{t("universalProvider.emptyHint", {
|
|
defaultValue: "点击下方「添加统一供应商」按钮创建一个",
|
|
})}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{providerList.map((provider) => (
|
|
<UniversalProviderCard
|
|
key={provider.id}
|
|
provider={provider}
|
|
onEdit={handleEdit}
|
|
onDelete={handleDeleteClick}
|
|
onSync={handleSyncClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 表单模态框 */}
|
|
<UniversalProviderFormModal
|
|
isOpen={isFormOpen}
|
|
onClose={() => {
|
|
setIsFormOpen(false);
|
|
setEditingProvider(null);
|
|
}}
|
|
onSave={handleSave}
|
|
onSaveAndSync={handleSaveAndSync}
|
|
editingProvider={editingProvider}
|
|
/>
|
|
|
|
{/* 删除确认对话框 */}
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.open}
|
|
title={t("universalProvider.deleteConfirmTitle", {
|
|
defaultValue: "删除统一供应商",
|
|
})}
|
|
message={t("universalProvider.deleteConfirmDescription", {
|
|
defaultValue: `确定要删除 "${deleteConfirm.name}" 吗?这将同时删除它在各应用中生成的供应商配置。`,
|
|
name: deleteConfirm.name,
|
|
})}
|
|
confirmText={t("common.delete", { defaultValue: "删除" })}
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setDeleteConfirm({ open: false, id: "", name: "" })}
|
|
/>
|
|
|
|
{/* 同步确认对话框 */}
|
|
<ConfirmDialog
|
|
isOpen={syncConfirm.open}
|
|
title={t("universalProvider.syncConfirmTitle", {
|
|
defaultValue: "同步统一供应商",
|
|
})}
|
|
message={t("universalProvider.syncConfirmDescription", {
|
|
defaultValue: `同步 "${syncConfirm.name}" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?`,
|
|
name: syncConfirm.name,
|
|
})}
|
|
confirmText={t("universalProvider.syncConfirm", {
|
|
defaultValue: "同步",
|
|
})}
|
|
onConfirm={handleSync}
|
|
onCancel={() => setSyncConfirm({ open: false, id: "", name: "" })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|