Files
ai-video-admin-frontend/src/components/pages/ComponentsModelsPage.tsx
Xin Wang 82ca52d438 Add connection testing feature to ComponentsModelsPage
Implemented a new connection testing functionality in ComponentsModelsPage, allowing users to test API connectivity. Added state management for testing status and results, along with UI updates to display connection success or failure. Enhanced button interactions for improved user experience during testing.
2026-06-08 00:02:18 +08:00

659 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Brain,
CheckCircle2,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
Loader2,
MoreHorizontal,
Pencil,
Plug,
Plus,
Search,
Trash2,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useState } from "react";
type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini";
/** 各资源类型可选的接口类型 */
const interfaceOptionsByType: Record<ModelType, InterfaceType[]> = {
LLM: ["openai"],
ASR: ["openai", "xfyun", "dashscope"],
TTS: ["openai", "xfyun", "dashscope"],
Realtime: ["openai", "gemini"],
Embedding: ["openai"],
};
const modelTypes: ModelType[] = ["LLM", "ASR", "TTS", "Realtime", "Embedding"];
type ModelResource = {
id: string;
name: string;
type: ModelType;
interfaceType: InterfaceType;
apiUrl: string;
apiKey: string;
};
const mockModels: ModelResource[] = [
{
id: "model_001",
name: "DeepSeek-V3",
type: "LLM",
interfaceType: "openai",
apiUrl: "https://api.deepseek.com/v1",
apiKey: "sk-deepseek-7f3a9c2e1b",
},
{
id: "model_002",
name: "Qwen-Max",
type: "LLM",
interfaceType: "openai",
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-qwen-4d8e2a6f0c",
},
{
id: "model_003",
name: "讯飞语音识别",
type: "ASR",
interfaceType: "xfyun",
apiUrl: "https://iat-api.xfyun.cn/v2/iat",
apiKey: "xf-asr-9b1c3d5e7a",
},
{
id: "model_004",
name: "Paraformer 识别",
type: "ASR",
interfaceType: "dashscope",
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/asr",
apiKey: "sk-paraformer-2e4f6a",
},
{
id: "model_005",
name: "讯飞语音合成",
type: "TTS",
interfaceType: "xfyun",
apiUrl: "https://tts-api.xfyun.cn/v2/tts",
apiKey: "xf-tts-6c8a0b2d4f",
},
{
id: "model_006",
name: "CosyVoice 合成",
type: "TTS",
interfaceType: "dashscope",
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts",
apiKey: "sk-cosyvoice-1a3c5e",
},
{
id: "model_007",
name: "OpenAI TTS",
type: "TTS",
interfaceType: "openai",
apiUrl: "https://api.openai.com/v1/audio/speech",
apiKey: "sk-openai-tts-8f0a2c",
},
{
id: "model_008",
name: "GPT Realtime",
type: "Realtime",
interfaceType: "openai",
apiUrl: "https://api.openai.com/v1/realtime",
apiKey: "sk-realtime-3b5d7f9a1c",
},
{
id: "model_009",
name: "Gemini Live",
type: "Realtime",
interfaceType: "gemini",
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKey: "gm-live-5e7a9c1b3d",
},
{
id: "model_010",
name: "text-embedding-3",
type: "Embedding",
interfaceType: "openai",
apiUrl: "https://api.openai.com/v1/embeddings",
apiKey: "sk-embed-0c2e4a6f8b",
},
{
id: "model_011",
name: "Kimi-K2",
type: "LLM",
interfaceType: "openai",
apiUrl: "https://api.moonshot.cn/v1",
apiKey: "sk-kimi-7a9c1e3b5d",
},
{
id: "model_012",
name: "BGE Embedding",
type: "Embedding",
interfaceType: "openai",
apiUrl: "https://api.siliconflow.cn/v1/embeddings",
apiKey: "sk-bge-2d4f6a8c0e",
},
];
type ModelForm = {
name: string;
type: ModelType;
interfaceType: InterfaceType;
apiUrl: string;
apiKey: string;
};
const emptyForm: ModelForm = {
name: "",
type: "LLM",
interfaceType: "openai",
apiUrl: "",
apiKey: "",
};
type TypeFilter = "全部" | ModelType;
const typeFilters: TypeFilter[] = ["全部", ...modelTypes];
export function ComponentsModelsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
const [currentPage, setCurrentPage] = useState(1);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ModelForm>(emptyForm);
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
function updateForm<K extends keyof ModelForm>(key: K, value: ModelForm[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
setTestResult("idle");
}
async function handleTestConnection() {
setTesting(true);
setTestResult("idle");
try {
// TODO: 接入真实疎通接口(按 form.interfaceType 区分调用方式)
await new Promise((resolve) => setTimeout(resolve, 900));
const reachable = Boolean(form.apiUrl.trim() && form.apiKey.trim());
setTestResult(reachable ? "ok" : "fail");
} catch {
setTestResult("fail");
} finally {
setTesting(false);
}
}
function handleTypeChange(type: ModelType) {
const options = interfaceOptionsByType[type];
setForm((prev) => ({
...prev,
type,
// 资源类型变化时,若当前接口类型不在新类型的可选项内则重置为首项
interfaceType: options.includes(prev.interfaceType)
? prev.interfaceType
: options[0],
}));
setTestResult("idle");
}
function openCreate() {
setEditingId(null);
setForm(emptyForm);
setShowKey(false);
setTestResult("idle");
setDialogOpen(true);
}
function openEdit(model: ModelResource) {
setEditingId(model.id);
setForm({
name: model.name,
type: model.type,
interfaceType: model.interfaceType,
apiUrl: model.apiUrl,
apiKey: model.apiKey,
});
setShowKey(false);
setTestResult("idle");
setDialogOpen(true);
}
const filteredModels = mockModels.filter((model) => {
if (typeFilter !== "全部" && model.type !== typeFilter) {
return false;
}
const keyword = searchQuery.trim().toLowerCase();
if (!keyword) {
return true;
}
return [model.name, model.id, model.type, model.interfaceType, model.apiUrl]
.join(" ")
.toLowerCase()
.includes(keyword);
});
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedModels = filteredModels.slice(pageStart, pageEnd);
function handleSearchChange(value: string) {
setSearchQuery(value);
setCurrentPage(1);
}
function handleFilterChange(filter: TypeFilter) {
setTypeFilter(filter);
setCurrentPage(1);
}
const interfaceOptions = interfaceOptionsByType[form.type];
const canSave = form.name.trim() && form.apiUrl.trim();
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6">
<div>
<h1 className="font-display display-lg text-ink"></h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
LLMASRTTSRealtime Embedding 访
</p>
</div>
<Button
size="lg"
className="w-full shrink-0 gap-2 sm:w-auto"
onClick={openCreate}
>
<Plus size={16} />
</Button>
</div>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
{typeFilters.map((filter) => (
<Button
key={filter}
variant={filter === typeFilter ? "default" : "outline"}
size="sm"
className={
filter === typeFilter
? "rounded-full"
: "rounded-full border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={() => handleFilterChange(filter)}
>
{filter}
</Button>
))}
</div>
<div className="relative w-full lg:w-[320px]">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
/>
<Input
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
placeholder="搜索模型名称、接口类型或 URL..."
/>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-hairline">
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
<div className="caption-label flex-1 text-muted-soft"></div>
<div className="caption-label w-[110px] text-muted-soft">
</div>
<div className="caption-label w-[110px] text-muted-soft">
</div>
<div className="caption-label w-[260px] text-muted-soft">
API URL
</div>
<div className="caption-label w-[116px] text-right text-muted-soft">
</div>
</div>
<div className="divide-y divide-hairline">
{paginatedModels.map((model) => (
<div
key={model.id}
className="flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4"
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{model.name}
</div>
<div className="mt-1 text-xs text-muted-soft">{model.id}</div>
</div>
<div className="md:w-[110px]">
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{model.type}
</Badge>
</div>
<div className="md:w-[110px]">
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{model.interfaceType}
</Badge>
</div>
<div className="truncate text-muted-foreground md:w-[260px]">
{model.apiUrl}
</div>
<div className="flex justify-end gap-2 md:w-[116px]">
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => openEdit(model)}
>
<Pencil size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${model.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem variant="destructive" className="rounded-lg">
<Trash2 size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{filteredModels.length === 0 && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
</div>
<div className="mt-2 text-sm text-muted-foreground">
</div>
</div>
)}
</div>
</div>
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>
{filteredModels.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredModels.length)} / 共 ${filteredModels.length} 个模型`}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={safeCurrentPage <= 1}
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
aria-label="上一页"
>
<ChevronLeft size={15} />
</Button>
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
(page) => (
<Button
key={page}
variant={page === safeCurrentPage ? "default" : "outline"}
size="sm"
className={[
"h-8 min-w-8 px-2",
page === safeCurrentPage
? ""
: "border-hairline-strong text-muted-foreground hover:text-foreground",
].join(" ")}
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
),
)}
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={safeCurrentPage >= totalPages}
onClick={() =>
setCurrentPage((page) => Math.min(totalPages, page + 1))
}
aria-label="下一页"
>
<ChevronRight size={15} />
</Button>
</div>
</div>
</section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
<DialogDescription>
访
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground"></div>
<Input
value={form.name}
autoFocus
onChange={(event) => updateForm("name", event.target.value)}
placeholder="请输入模型名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Select
value={form.type}
onValueChange={(value) => handleTypeChange(value as ModelType)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder="请选择资源类型" />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{modelTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Select
value={form.interfaceType}
onValueChange={(value) =>
updateForm("interfaceType", value as InterfaceType)
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder="请选择接口类型" />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{interfaceOptions.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
API URL
</div>
<Input
value={form.apiUrl}
onChange={(event) => updateForm("apiUrl", event.target.value)}
placeholder="https://api.example.com/v1"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
API Key
</div>
<div className="relative">
<Input
value={form.apiKey}
type={showKey ? "text" : "password"}
onChange={(event) => updateForm("apiKey", event.target.value)}
placeholder="请输入 API Key"
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
/>
<button
type="button"
onClick={() => setShowKey((value) => !value)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground"
aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
</div>
<DialogFooter className="sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={!canSave || testing}
onClick={handleTestConnection}
>
{testing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plug size={14} />
)}
</Button>
{testResult === "ok" && (
<span className="flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 size={14} />
</span>
)}
{testResult === "fail" && (
<span className="flex items-center gap-1.5 text-xs text-destructive">
<XCircle size={14} />
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setDialogOpen(false)}
>
</Button>
<Button
className="gap-2"
disabled={!canSave}
onClick={() => setDialogOpen(false)}
>
<Brain size={16} />
{editingId ? "保存" : "添加"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}