2025-09-19 08:30:29 +08:00
|
|
|
|
import React, { useEffect, useState } from "react";
|
2025-08-27 11:00:53 +08:00
|
|
|
|
import { Provider } from "../types";
|
2025-09-06 23:57:10 +08:00
|
|
|
|
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
2025-09-08 15:38:06 +08:00
|
|
|
|
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
2025-09-19 08:30:29 +08:00
|
|
|
|
import { AppType } from "../lib/tauri-api";
|
|
|
|
|
|
import { applyProviderToVSCode, detectApplied } from "../utils/vscodeSettings";
|
2025-09-11 22:33:55 +08:00
|
|
|
|
// 不再在列表中显示分类徽章,避免造成困惑
|
2025-08-04 22:16:26 +08:00
|
|
|
|
|
|
|
|
|
|
interface ProviderListProps {
|
2025-08-27 11:00:53 +08:00
|
|
|
|
providers: Record<string, Provider>;
|
|
|
|
|
|
currentProviderId: string;
|
|
|
|
|
|
onSwitch: (id: string) => void;
|
|
|
|
|
|
onDelete: (id: string) => void;
|
|
|
|
|
|
onEdit: (id: string) => void;
|
2025-09-19 08:30:29 +08:00
|
|
|
|
appType?: AppType;
|
|
|
|
|
|
onNotify?: (message: string, type: "success" | "error", duration?: number) => void;
|
2025-08-04 22:16:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ProviderList: React.FC<ProviderListProps> = ({
|
|
|
|
|
|
providers,
|
|
|
|
|
|
currentProviderId,
|
|
|
|
|
|
onSwitch,
|
2025-08-05 09:51:41 +08:00
|
|
|
|
onDelete,
|
2025-08-27 11:00:53 +08:00
|
|
|
|
onEdit,
|
2025-09-19 08:30:29 +08:00
|
|
|
|
appType,
|
|
|
|
|
|
onNotify,
|
2025-08-04 22:16:26 +08:00
|
|
|
|
}) => {
|
2025-09-06 23:57:10 +08:00
|
|
|
|
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
2025-08-07 15:48:30 +08:00
|
|
|
|
const getApiUrl = (provider: Provider): string => {
|
|
|
|
|
|
try {
|
2025-09-06 23:57:10 +08:00
|
|
|
|
const cfg = provider.settingsConfig;
|
|
|
|
|
|
// Claude/Anthropic: 从 env 中读取
|
|
|
|
|
|
if (cfg?.env?.ANTHROPIC_BASE_URL) {
|
|
|
|
|
|
return cfg.env.ANTHROPIC_BASE_URL;
|
2025-08-07 15:48:30 +08:00
|
|
|
|
}
|
2025-09-06 23:57:10 +08:00
|
|
|
|
// Codex: 从 TOML 配置中解析 base_url
|
|
|
|
|
|
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
|
|
|
|
|
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
|
|
|
|
|
|
if (match && match[1]) return match[1];
|
|
|
|
|
|
}
|
|
|
|
|
|
return "未配置官网地址";
|
2025-08-07 15:48:30 +08:00
|
|
|
|
} catch {
|
2025-08-27 11:00:53 +08:00
|
|
|
|
return "配置错误";
|
2025-08-07 15:48:30 +08:00
|
|
|
|
}
|
2025-08-27 11:00:53 +08:00
|
|
|
|
};
|
2025-08-07 15:48:30 +08:00
|
|
|
|
|
2025-08-06 09:56:27 +08:00
|
|
|
|
const handleUrlClick = async (url: string) => {
|
|
|
|
|
|
try {
|
2025-08-27 11:00:53 +08:00
|
|
|
|
await window.api.openExternal(url);
|
2025-08-06 09:56:27 +08:00
|
|
|
|
} catch (error) {
|
2025-08-27 11:00:53 +08:00
|
|
|
|
console.error("打开链接失败:", error);
|
2025-08-06 09:56:27 +08:00
|
|
|
|
}
|
2025-08-27 11:00:53 +08:00
|
|
|
|
};
|
2025-08-06 09:56:27 +08:00
|
|
|
|
|
2025-09-19 08:30:29 +08:00
|
|
|
|
// 解析 Codex 配置中的 base_url(仅用于 VS Code 写入)
|
|
|
|
|
|
const getCodexBaseUrl = (provider: Provider): string | undefined => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cfg = provider.settingsConfig;
|
|
|
|
|
|
const text = typeof cfg?.config === "string" ? cfg.config : "";
|
|
|
|
|
|
if (!text) return undefined;
|
|
|
|
|
|
const m = text.match(/base_url\s*=\s*"([^"]+)"/);
|
|
|
|
|
|
return m && m[1] ? m[1] : undefined;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否“已应用”变化
|
|
|
|
|
|
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const check = async () => {
|
|
|
|
|
|
if (appType !== "codex" || !currentProviderId) {
|
|
|
|
|
|
setVscodeAppliedFor(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const status = await window.api.getVSCodeSettingsStatus();
|
|
|
|
|
|
if (!status.exists) {
|
|
|
|
|
|
setVscodeAppliedFor(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const content = await window.api.readVSCodeSettings();
|
|
|
|
|
|
const detected = detectApplied(content);
|
|
|
|
|
|
// 认为“已应用”的条件:存在任意一个我们管理的键
|
|
|
|
|
|
const applied = detected.hasApiBase || detected.hasPreferredAuthMethod;
|
|
|
|
|
|
setVscodeAppliedFor(applied ? currentProviderId : null);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setVscodeAppliedFor(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
check();
|
|
|
|
|
|
}, [appType, currentProviderId]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleApplyToVSCode = async (provider: Provider) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const status = await window.api.getVSCodeSettingsStatus();
|
|
|
|
|
|
if (!status.exists) {
|
|
|
|
|
|
onNotify?.("未找到 VS Code 用户设置文件 (settings.json)", "error", 3000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const raw = await window.api.readVSCodeSettings();
|
|
|
|
|
|
|
|
|
|
|
|
const isOfficial = provider.category === "official";
|
|
|
|
|
|
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
|
|
|
|
|
|
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
|
|
|
|
|
|
|
|
|
|
|
|
if (next === raw) {
|
|
|
|
|
|
// 幂等:没有变化也提示成功
|
|
|
|
|
|
onNotify?.("已应用到 VS Code", "success", 1500);
|
|
|
|
|
|
setVscodeAppliedFor(provider.id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await window.api.writeVSCodeSettings(next);
|
|
|
|
|
|
onNotify?.("已应用到 VS Code", "success", 1500);
|
|
|
|
|
|
setVscodeAppliedFor(provider.id);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
const msg = (e && e.message) ? e.message : "应用到 VS Code 失败";
|
|
|
|
|
|
onNotify?.(msg, "error", 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRemoveFromVSCode = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const status = await window.api.getVSCodeSettingsStatus();
|
|
|
|
|
|
if (!status.exists) {
|
|
|
|
|
|
onNotify?.("未找到 VS Code 用户设置文件 (settings.json)", "error", 3000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const raw = await window.api.readVSCodeSettings();
|
|
|
|
|
|
const next = applyProviderToVSCode(raw, { baseUrl: undefined, isOfficial: true });
|
|
|
|
|
|
if (next === raw) {
|
|
|
|
|
|
onNotify?.("已从 VS Code 移除", "success", 1500);
|
|
|
|
|
|
setVscodeAppliedFor(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await window.api.writeVSCodeSettings(next);
|
|
|
|
|
|
onNotify?.("已从 VS Code 移除", "success", 1500);
|
|
|
|
|
|
setVscodeAppliedFor(null);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
const msg = (e && e.message) ? e.message : "移除失败";
|
|
|
|
|
|
onNotify?.(msg, "error", 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-07 22:29:08 +08:00
|
|
|
|
// 对供应商列表进行排序
|
|
|
|
|
|
const sortedProviders = Object.values(providers).sort((a, b) => {
|
|
|
|
|
|
// 按添加时间排序
|
|
|
|
|
|
// 没有时间戳的视为最早添加的(排在最前面)
|
|
|
|
|
|
// 有时间戳的按时间升序排列
|
|
|
|
|
|
const timeA = a.createdAt || 0;
|
|
|
|
|
|
const timeB = b.createdAt || 0;
|
2025-09-13 15:36:43 +08:00
|
|
|
|
|
2025-09-07 22:29:08 +08:00
|
|
|
|
// 如果都没有时间戳,按名称排序
|
|
|
|
|
|
if (timeA === 0 && timeB === 0) {
|
2025-09-13 15:36:43 +08:00
|
|
|
|
return a.name.localeCompare(b.name, "zh-CN");
|
2025-09-07 22:29:08 +08:00
|
|
|
|
}
|
2025-09-13 15:36:43 +08:00
|
|
|
|
|
2025-09-07 22:29:08 +08:00
|
|
|
|
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
|
|
|
|
|
if (timeA === 0) return -1;
|
|
|
|
|
|
if (timeB === 0) return 1;
|
2025-09-13 15:36:43 +08:00
|
|
|
|
|
2025-09-07 22:29:08 +08:00
|
|
|
|
// 都有时间戳,按时间升序
|
|
|
|
|
|
return timeA - timeB;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-04 22:16:26 +08:00
|
|
|
|
return (
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<div className="space-y-4">
|
2025-09-07 22:29:08 +08:00
|
|
|
|
{sortedProviders.length === 0 ? (
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<div className="text-center py-12">
|
2025-09-08 11:48:05 +08:00
|
|
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
|
|
|
|
|
<Users size={24} className="text-gray-400" />
|
2025-09-06 16:21:21 +08:00
|
|
|
|
</div>
|
2025-09-08 15:57:29 +08:00
|
|
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
2025-09-06 16:21:21 +08:00
|
|
|
|
还没有添加任何供应商
|
|
|
|
|
|
</h3>
|
2025-09-08 15:57:29 +08:00
|
|
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
2025-09-06 16:21:21 +08:00
|
|
|
|
点击右上角的"添加供应商"按钮开始配置您的第一个API供应商
|
|
|
|
|
|
</p>
|
2025-08-04 22:16:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<div className="space-y-3">
|
2025-09-07 22:29:08 +08:00
|
|
|
|
{sortedProviders.map((provider) => {
|
2025-08-27 11:00:53 +08:00
|
|
|
|
const isCurrent = provider.id === currentProviderId;
|
2025-09-06 16:21:21 +08:00
|
|
|
|
const apiUrl = getApiUrl(provider);
|
2025-08-27 11:00:53 +08:00
|
|
|
|
|
2025-08-04 22:16:26 +08:00
|
|
|
|
return (
|
2025-08-27 11:00:53 +08:00
|
|
|
|
<div
|
|
|
|
|
|
key={provider.id}
|
2025-09-08 15:38:06 +08:00
|
|
|
|
className={cn(
|
2025-09-13 15:36:43 +08:00
|
|
|
|
isCurrent ? cardStyles.selected : cardStyles.interactive,
|
2025-09-08 15:38:06 +08:00
|
|
|
|
)}
|
2025-08-04 22:16:26 +08:00
|
|
|
|
>
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-2">
|
2025-09-08 15:57:29 +08:00
|
|
|
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
2025-09-06 16:21:21 +08:00
|
|
|
|
{provider.name}
|
|
|
|
|
|
</h3>
|
2025-09-11 22:33:55 +08:00
|
|
|
|
{/* 分类徽章已移除 */}
|
2025-09-06 16:21:21 +08:00
|
|
|
|
{isCurrent && (
|
2025-09-08 15:38:06 +08:00
|
|
|
|
<div className={badgeStyles.success}>
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<CheckCircle2 size={12} />
|
|
|
|
|
|
当前使用
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-06 23:57:10 +08:00
|
|
|
|
<div className="flex items-center gap-2 text-sm">
|
2025-09-06 16:21:21 +08:00
|
|
|
|
{provider.websiteUrl ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleUrlClick(provider.websiteUrl!);
|
|
|
|
|
|
}}
|
2025-09-08 15:57:29 +08:00
|
|
|
|
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
|
2025-09-06 16:21:21 +08:00
|
|
|
|
title={`访问 ${provider.websiteUrl}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{provider.websiteUrl}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
2025-09-07 11:36:09 +08:00
|
|
|
|
<span
|
2025-09-08 15:57:29 +08:00
|
|
|
|
className="text-gray-500 dark:text-gray-400"
|
2025-09-07 11:36:09 +08:00
|
|
|
|
title={apiUrl}
|
|
|
|
|
|
>
|
2025-09-06 16:21:21 +08:00
|
|
|
|
{apiUrl}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-08-06 09:56:27 +08:00
|
|
|
|
</div>
|
2025-08-27 11:00:53 +08:00
|
|
|
|
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<div className="flex items-center gap-2 ml-4">
|
2025-09-19 09:19:53 +08:00
|
|
|
|
{appType === "codex" && isCurrent && provider.category !== "official" && (
|
2025-09-19 08:30:29 +08:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
vscodeAppliedFor === provider.id
|
|
|
|
|
|
? handleRemoveFromVSCode()
|
|
|
|
|
|
: handleApplyToVSCode(provider)
|
|
|
|
|
|
}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
|
|
|
|
|
vscodeAppliedFor === provider.id
|
|
|
|
|
|
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
|
|
|
|
|
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={
|
|
|
|
|
|
vscodeAppliedFor === provider.id
|
|
|
|
|
|
? "从 VS Code 移除我们写入的配置"
|
|
|
|
|
|
: "将当前供应商应用到 VS Code"
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{vscodeAppliedFor === provider.id ? "从 VS Code 移除" : "应用到 VS Code"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-09-06 16:21:21 +08:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onSwitch(provider.id)}
|
|
|
|
|
|
disabled={isCurrent}
|
2025-09-08 15:38:06 +08:00
|
|
|
|
className={cn(
|
|
|
|
|
|
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
2025-09-06 16:21:21 +08:00
|
|
|
|
isCurrent
|
2025-09-08 15:57:29 +08:00
|
|
|
|
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
2025-09-13 15:36:43 +08:00
|
|
|
|
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
2025-09-08 15:38:06 +08:00
|
|
|
|
)}
|
2025-09-06 16:21:21 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Play size={14} />
|
|
|
|
|
|
{isCurrent ? "使用中" : "启用"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onEdit(provider.id)}
|
2025-09-08 15:38:06 +08:00
|
|
|
|
className={buttonStyles.icon}
|
2025-09-06 16:21:21 +08:00
|
|
|
|
title="编辑供应商"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit3 size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onDelete(provider.id)}
|
|
|
|
|
|
disabled={isCurrent}
|
2025-09-08 15:38:06 +08:00
|
|
|
|
className={cn(
|
|
|
|
|
|
buttonStyles.icon,
|
2025-09-06 16:21:21 +08:00
|
|
|
|
isCurrent
|
2025-09-08 11:48:05 +08:00
|
|
|
|
? "text-gray-400 cursor-not-allowed"
|
2025-09-13 15:36:43 +08:00
|
|
|
|
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
|
2025-09-08 15:38:06 +08:00
|
|
|
|
)}
|
2025-09-06 16:21:21 +08:00
|
|
|
|
title="删除供应商"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-08-04 22:16:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-27 11:00:53 +08:00
|
|
|
|
);
|
2025-08-04 22:16:26 +08:00
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-08-27 11:00:53 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
2025-08-04 22:16:26 +08:00
|
|
|
|
|
2025-08-27 11:00:53 +08:00
|
|
|
|
export default ProviderList;
|