fix: 修复 Gemini API Key 和 Vertex AI 配置页面白屏问题

- GeminiApiKeySection: 添加 entries 和 excluded_models 的空值保护
- VertexAISection: 添加 entries 和 models 的空值保护
- 确保数组字段在 undefined 时不会导致组件崩溃
This commit is contained in:
coso
2025-12-24 23:50:59 +08:00
parent 9ca4dead61
commit c1d350d594
2 changed files with 379 additions and 346 deletions
@@ -3,14 +3,16 @@ import { Plus, Trash2, Key, Globe, Ban, Eye, EyeOff } from "lucide-react";
import type { GeminiApiKeyEntry } from "@/hooks/useTauri";
interface GeminiApiKeySectionProps {
entries: GeminiApiKeyEntry[];
entries: GeminiApiKeyEntry[] | undefined;
onChange: (entries: GeminiApiKeyEntry[]) => void;
}
export function GeminiApiKeySection({
entries,
entries = [],
onChange,
}: GeminiApiKeySectionProps) {
// 确保 entries 是数组
const safeEntries = Array.isArray(entries) ? entries : [];
const [showKeys, setShowKeys] = useState<Set<string>>(new Set());
const [editingExclusions, setEditingExclusions] = useState<string | null>(
null,
@@ -36,33 +38,39 @@ export function GeminiApiKeySection({
excluded_models: [],
disabled: false,
};
onChange([...entries, newEntry]);
onChange([...safeEntries, newEntry]);
};
const updateEntry = (id: string, updates: Partial<GeminiApiKeyEntry>) => {
onChange(entries.map((e) => (e.id === id ? { ...e, ...updates } : e)));
onChange(safeEntries.map((e) => (e.id === id ? { ...e, ...updates } : e)));
};
const removeEntry = (id: string) => {
onChange(entries.filter((e) => e.id !== id));
onChange(safeEntries.filter((e) => e.id !== id));
};
const addExclusion = (id: string) => {
if (!exclusionInput.trim()) return;
const entry = entries.find((e) => e.id === id);
const entry = safeEntries.find((e) => e.id === id);
if (entry) {
const currentExcluded = Array.isArray(entry.excluded_models)
? entry.excluded_models
: [];
updateEntry(id, {
excluded_models: [...entry.excluded_models, exclusionInput.trim()],
excluded_models: [...currentExcluded, exclusionInput.trim()],
});
setExclusionInput("");
}
};
const removeExclusion = (id: string, model: string) => {
const entry = entries.find((e) => e.id === id);
const entry = safeEntries.find((e) => e.id === id);
if (entry) {
const currentExcluded = Array.isArray(entry.excluded_models)
? entry.excluded_models
: [];
updateEntry(id, {
excluded_models: entry.excluded_models.filter((m) => m !== model),
excluded_models: currentExcluded.filter((m) => m !== model),
});
}
};
@@ -88,175 +96,185 @@ export function GeminiApiKeySection({
</button>
</div>
{entries.length === 0 ? (
{safeEntries.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground">
<p>暂无 Gemini API Key</p>
<p className="text-xs mt-1">点击上方"添加"按钮添加 API Key</p>
</div>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<div
key={entry.id}
className={`rounded-lg border p-4 space-y-3 ${
entry.disabled ? "opacity-60 bg-muted/30" : ""
}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">
{entry.id}
</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={entry.disabled}
onChange={(e) =>
updateEntry(entry.id, { disabled: e.target.checked })
}
className="w-3 h-3"
/>
禁用
</label>
<button
onClick={() => removeEntry(entry.id)}
className="p-1 rounded hover:bg-red-100 text-red-500"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* API Key */}
<div>
<label className="block text-xs font-medium mb-1">
API Key
</label>
<div className="relative">
<input
type={showKeys.has(entry.id) ? "text" : "password"}
value={entry.api_key}
onChange={(e) =>
updateEntry(entry.id, { api_key: e.target.value })
}
placeholder="AIzaSy..."
className="w-full px-3 py-1.5 pr-10 rounded border bg-background text-sm font-mono"
/>
<button
type="button"
onClick={() => toggleShowKey(entry.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted"
>
{showKeys.has(entry.id) ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Base URL */}
<div>
<label className="block text-xs font-medium mb-1">
<Globe className="h-3 w-3 inline mr-1" />
Base URL (可选)
</label>
<input
type="text"
value={entry.base_url || ""}
onChange={(e) =>
updateEntry(entry.id, { base_url: e.target.value || null })
}
placeholder="https://generativelanguage.googleapis.com"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Proxy URL */}
<div>
<label className="block text-xs font-medium mb-1">
代理 URL (可选)
</label>
<input
type="text"
value={entry.proxy_url || ""}
onChange={(e) =>
updateEntry(entry.id, { proxy_url: e.target.value || null })
}
placeholder="socks5://127.0.0.1:1080"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Excluded Models */}
<div>
<label className="block text-xs font-medium mb-1">
<Ban className="h-3 w-3 inline mr-1" />
排除模型
</label>
<div className="flex flex-wrap gap-1 mb-2">
{entry.excluded_models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
>
{model}
<button
onClick={() => removeExclusion(entry.id, model)}
className="hover:text-red-900"
>
×
</button>
</span>
))}
</div>
{editingExclusions === entry.id ? (
<div className="flex gap-2">
<input
type="text"
value={exclusionInput}
onChange={(e) => setExclusionInput(e.target.value)}
placeholder="gemini-2.5-pro 或 *-preview"
className="flex-1 px-2 py-1 rounded border bg-background text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
addExclusion(entry.id);
{safeEntries.map((entry) => {
// 确保 excluded_models 是数组
const excludedModels = Array.isArray(entry.excluded_models)
? entry.excluded_models
: [];
return (
<div
key={entry.id}
className={`rounded-lg border p-4 space-y-3 ${
entry.disabled ? "opacity-60 bg-muted/30" : ""
}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">
{entry.id}
</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={entry.disabled}
onChange={(e) =>
updateEntry(entry.id, { disabled: e.target.checked })
}
}}
/>
className="w-3 h-3"
/>
禁用
</label>
<button
onClick={() => addExclusion(entry.id)}
className="px-2 py-1 rounded bg-primary text-primary-foreground text-xs"
onClick={() => removeEntry(entry.id)}
className="p-1 rounded hover:bg-red-100 text-red-500"
title="删除"
>
添加
</button>
<button
onClick={() => {
setEditingExclusions(null);
setExclusionInput("");
}}
className="px-2 py-1 rounded border text-xs"
>
完成
<Trash2 className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setEditingExclusions(entry.id)}
className="text-xs text-primary hover:underline"
>
+ 添加排除模型
</button>
)}
<p className="text-xs text-muted-foreground mt-1">
支持通配符,如 *-preview 匹配所有预览模型
</p>
</div>
{/* API Key */}
<div>
<label className="block text-xs font-medium mb-1">
API Key
</label>
<div className="relative">
<input
type={showKeys.has(entry.id) ? "text" : "password"}
value={entry.api_key || ""}
onChange={(e) =>
updateEntry(entry.id, { api_key: e.target.value })
}
placeholder="AIzaSy..."
className="w-full px-3 py-1.5 pr-10 rounded border bg-background text-sm font-mono"
/>
<button
type="button"
onClick={() => toggleShowKey(entry.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted"
>
{showKeys.has(entry.id) ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Base URL */}
<div>
<label className="block text-xs font-medium mb-1">
<Globe className="h-3 w-3 inline mr-1" />
Base URL (可选)
</label>
<input
type="text"
value={entry.base_url || ""}
onChange={(e) =>
updateEntry(entry.id, {
base_url: e.target.value || null,
})
}
placeholder="https://generativelanguage.googleapis.com"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Proxy URL */}
<div>
<label className="block text-xs font-medium mb-1">
代理 URL (可选)
</label>
<input
type="text"
value={entry.proxy_url || ""}
onChange={(e) =>
updateEntry(entry.id, {
proxy_url: e.target.value || null,
})
}
placeholder="socks5://127.0.0.1:1080"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Excluded Models */}
<div>
<label className="block text-xs font-medium mb-1">
<Ban className="h-3 w-3 inline mr-1" />
排除模型
</label>
<div className="flex flex-wrap gap-1 mb-2">
{excludedModels.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
>
{model}
<button
onClick={() => removeExclusion(entry.id, model)}
className="hover:text-red-900"
>
×
</button>
</span>
))}
</div>
{editingExclusions === entry.id ? (
<div className="flex gap-2">
<input
type="text"
value={exclusionInput}
onChange={(e) => setExclusionInput(e.target.value)}
placeholder="gemini-2.5-pro 或 *-preview"
className="flex-1 px-2 py-1 rounded border bg-background text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
addExclusion(entry.id);
}
}}
/>
<button
onClick={() => addExclusion(entry.id)}
className="px-2 py-1 rounded bg-primary text-primary-foreground text-xs"
>
添加
</button>
<button
onClick={() => {
setEditingExclusions(null);
setExclusionInput("");
}}
className="px-2 py-1 rounded border text-xs"
>
完成
</button>
</div>
) : (
<button
onClick={() => setEditingExclusions(entry.id)}
className="text-xs text-primary hover:underline"
>
+ 添加排除模型
</button>
)}
<p className="text-xs text-muted-foreground mt-1">
支持通配符,如 *-preview 匹配所有预览模型
</p>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>
+195 -180
View File
@@ -11,11 +11,16 @@ import {
import type { VertexApiKeyEntry, VertexModelAlias } from "@/hooks/useTauri";
interface VertexAISectionProps {
entries: VertexApiKeyEntry[];
entries: VertexApiKeyEntry[] | undefined;
onChange: (entries: VertexApiKeyEntry[]) => void;
}
export function VertexAISection({ entries, onChange }: VertexAISectionProps) {
export function VertexAISection({
entries = [],
onChange,
}: VertexAISectionProps) {
// 确保 entries 是数组
const safeEntries = Array.isArray(entries) ? entries : [];
const [showKeys, setShowKeys] = useState<Set<string>>(new Set());
const [editingAliases, setEditingAliases] = useState<string | null>(null);
const [aliasName, setAliasName] = useState("");
@@ -40,27 +45,28 @@ export function VertexAISection({ entries, onChange }: VertexAISectionProps) {
proxy_url: null,
disabled: false,
};
onChange([...entries, newEntry]);
onChange([...safeEntries, newEntry]);
};
const updateEntry = (id: string, updates: Partial<VertexApiKeyEntry>) => {
onChange(entries.map((e) => (e.id === id ? { ...e, ...updates } : e)));
onChange(safeEntries.map((e) => (e.id === id ? { ...e, ...updates } : e)));
};
const removeEntry = (id: string) => {
onChange(entries.filter((e) => e.id !== id));
onChange(safeEntries.filter((e) => e.id !== id));
};
const addAlias = (id: string) => {
if (!aliasName.trim() || !aliasAlias.trim()) return;
const entry = entries.find((e) => e.id === id);
const entry = safeEntries.find((e) => e.id === id);
if (entry) {
const newAlias: VertexModelAlias = {
name: aliasName.trim(),
alias: aliasAlias.trim(),
};
const currentModels = Array.isArray(entry.models) ? entry.models : [];
updateEntry(id, {
models: [...entry.models, newAlias],
models: [...currentModels, newAlias],
});
setAliasName("");
setAliasAlias("");
@@ -68,10 +74,11 @@ export function VertexAISection({ entries, onChange }: VertexAISectionProps) {
};
const removeAlias = (id: string, aliasToRemove: string) => {
const entry = entries.find((e) => e.id === id);
const entry = safeEntries.find((e) => e.id === id);
if (entry) {
const currentModels = Array.isArray(entry.models) ? entry.models : [];
updateEntry(id, {
models: entry.models.filter((m) => m.alias !== aliasToRemove),
models: currentModels.filter((m) => m.alias !== aliasToRemove),
});
}
};
@@ -97,186 +104,194 @@ export function VertexAISection({ entries, onChange }: VertexAISectionProps) {
</button>
</div>
{entries.length === 0 ? (
{safeEntries.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground">
<p>暂无 Vertex AI 凭证</p>
<p className="text-xs mt-1">点击上方"添加"按钮添加凭证</p>
</div>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<div
key={entry.id}
className={`rounded-lg border p-4 space-y-3 ${
entry.disabled ? "opacity-60 bg-muted/30" : ""
}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">
{entry.id}
</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={entry.disabled}
onChange={(e) =>
updateEntry(entry.id, { disabled: e.target.checked })
}
className="w-3 h-3"
/>
禁用
</label>
<button
onClick={() => removeEntry(entry.id)}
className="p-1 rounded hover:bg-red-100 text-red-500"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* API Key */}
<div>
<label className="block text-xs font-medium mb-1">
API Key
</label>
<div className="relative">
<input
type={showKeys.has(entry.id) ? "text" : "password"}
value={entry.api_key}
onChange={(e) =>
updateEntry(entry.id, { api_key: e.target.value })
}
placeholder="vk-..."
className="w-full px-3 py-1.5 pr-10 rounded border bg-background text-sm font-mono"
/>
<button
type="button"
onClick={() => toggleShowKey(entry.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted"
>
{showKeys.has(entry.id) ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Base URL */}
<div>
<label className="block text-xs font-medium mb-1">
<Globe className="h-3 w-3 inline mr-1" />
Base URL
</label>
<input
type="text"
value={entry.base_url || ""}
onChange={(e) =>
updateEntry(entry.id, { base_url: e.target.value || null })
}
placeholder="https://example.com/api"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Proxy URL */}
<div>
<label className="block text-xs font-medium mb-1">
代理 URL (可选)
</label>
<input
type="text"
value={entry.proxy_url || ""}
onChange={(e) =>
updateEntry(entry.id, { proxy_url: e.target.value || null })
}
placeholder="socks5://127.0.0.1:1080"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Model Aliases */}
<div>
<label className="block text-xs font-medium mb-1">
模型别名映射
</label>
<div className="space-y-1 mb-2">
{entry.models.map((model) => (
<div
key={model.alias}
className="flex items-center gap-2 px-2 py-1 rounded bg-muted/50 text-sm"
{safeEntries.map((entry) => {
// 确保 models 是数组
const models = Array.isArray(entry.models) ? entry.models : [];
return (
<div
key={entry.id}
className={`rounded-lg border p-4 space-y-3 ${
entry.disabled ? "opacity-60 bg-muted/30" : ""
}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">
{entry.id}
</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={entry.disabled}
onChange={(e) =>
updateEntry(entry.id, { disabled: e.target.checked })
}
className="w-3 h-3"
/>
禁用
</label>
<button
onClick={() => removeEntry(entry.id)}
className="p-1 rounded hover:bg-red-100 text-red-500"
title="删除"
>
<span className="font-mono text-xs">{model.alias}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs text-primary">
{model.name}
</span>
<button
onClick={() => removeAlias(entry.id, model.alias)}
className="ml-auto text-red-500 hover:text-red-700"
>
×
</button>
</div>
))}
</div>
{editingAliases === entry.id ? (
<div className="space-y-2">
<div className="flex gap-2 items-center">
<input
type="text"
value={aliasAlias}
onChange={(e) => setAliasAlias(e.target.value)}
placeholder="客户端别名"
className="flex-1 px-2 py-1 rounded border bg-background text-sm"
/>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<input
type="text"
value={aliasName}
onChange={(e) => setAliasName(e.target.value)}
placeholder="上游模型名"
className="flex-1 px-2 py-1 rounded border bg-background text-sm"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => addAlias(entry.id)}
className="px-2 py-1 rounded bg-primary text-primary-foreground text-xs"
>
添加
</button>
<button
onClick={() => {
setEditingAliases(null);
setAliasName("");
setAliasAlias("");
}}
className="px-2 py-1 rounded border text-xs"
>
完成
</button>
</div>
<Trash2 className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setEditingAliases(entry.id)}
className="text-xs text-primary hover:underline"
>
+ 添加模型别名
</button>
)}
<p className="text-xs text-muted-foreground mt-1">
将客户端请求的模型名映射到上游实际模型名
</p>
</div>
{/* API Key */}
<div>
<label className="block text-xs font-medium mb-1">
API Key
</label>
<div className="relative">
<input
type={showKeys.has(entry.id) ? "text" : "password"}
value={entry.api_key || ""}
onChange={(e) =>
updateEntry(entry.id, { api_key: e.target.value })
}
placeholder="vk-..."
className="w-full px-3 py-1.5 pr-10 rounded border bg-background text-sm font-mono"
/>
<button
type="button"
onClick={() => toggleShowKey(entry.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted"
>
{showKeys.has(entry.id) ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Base URL */}
<div>
<label className="block text-xs font-medium mb-1">
<Globe className="h-3 w-3 inline mr-1" />
Base URL
</label>
<input
type="text"
value={entry.base_url || ""}
onChange={(e) =>
updateEntry(entry.id, {
base_url: e.target.value || null,
})
}
placeholder="https://example.com/api"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Proxy URL */}
<div>
<label className="block text-xs font-medium mb-1">
代理 URL (可选)
</label>
<input
type="text"
value={entry.proxy_url || ""}
onChange={(e) =>
updateEntry(entry.id, {
proxy_url: e.target.value || null,
})
}
placeholder="socks5://127.0.0.1:1080"
className="w-full px-3 py-1.5 rounded border bg-background text-sm"
/>
</div>
{/* Model Aliases */}
<div>
<label className="block text-xs font-medium mb-1">
模型别名映射
</label>
<div className="space-y-1 mb-2">
{models.map((model) => (
<div
key={model.alias}
className="flex items-center gap-2 px-2 py-1 rounded bg-muted/50 text-sm"
>
<span className="font-mono text-xs">{model.alias}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs text-primary">
{model.name}
</span>
<button
onClick={() => removeAlias(entry.id, model.alias)}
className="ml-auto text-red-500 hover:text-red-700"
>
×
</button>
</div>
))}
</div>
{editingAliases === entry.id ? (
<div className="space-y-2">
<div className="flex gap-2 items-center">
<input
type="text"
value={aliasAlias}
onChange={(e) => setAliasAlias(e.target.value)}
placeholder="客户端别名"
className="flex-1 px-2 py-1 rounded border bg-background text-sm"
/>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<input
type="text"
value={aliasName}
onChange={(e) => setAliasName(e.target.value)}
placeholder="上游模型名"
className="flex-1 px-2 py-1 rounded border bg-background text-sm"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => addAlias(entry.id)}
className="px-2 py-1 rounded bg-primary text-primary-foreground text-xs"
>
添加
</button>
<button
onClick={() => {
setEditingAliases(null);
setAliasName("");
setAliasAlias("");
}}
className="px-2 py-1 rounded border text-xs"
>
完成
</button>
</div>
</div>
) : (
<button
onClick={() => setEditingAliases(entry.id)}
className="text-xs text-primary hover:underline"
>
+ 添加模型别名
</button>
)}
<p className="text-xs text-muted-foreground mt-1">
将客户端请求的模型名映射到上游实际模型名
</p>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>