Files
ai-video-fullstack/frontend/src/components/pages/ComponentsModelsPage.tsx
Xin Wang c64b7dcf99 Enhance credential management and testing functionality
- Introduce new fields for voice, speed, and language in the AssistantConfig and ProviderCredential models to support TTS and ASR configurations.
- Update the database schema and seeding script to accommodate the new fields, ensuring backward compatibility.
- Implement credential testing endpoints and logic to validate OpenAI-compatible credentials, enhancing user experience and reliability.
- Modify frontend components to include new fields in the credential forms and improve connection testing feedback.
- Refactor related services and API interactions to support the new credential testing feature.
2026-06-09 14:42:25 +08:00

973 lines
33 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,
Copy,
Eye,
EyeOff,
HelpCircle,
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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import {
credentialsApi,
type Credential,
type InterfaceType,
type ModelType,
} from "@/lib/api";
/** 各资源类型可选的接口类型 */
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"];
// 列表项类型直接复用 API 契约(camelCase,api_key 已打码)
type ModelResource = Credential;
type ModelForm = {
name: string;
modelId: string;
type: ModelType;
interfaceType: InterfaceType;
apiUrl: string;
apiKey: string;
voice: string;
speed: number;
language: string;
};
const emptyForm: ModelForm = {
name: "",
modelId: "",
type: "LLM",
interfaceType: "openai",
apiUrl: "",
apiKey: "",
voice: "",
speed: 1,
language: "",
};
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 [models, setModels] = useState<ModelResource[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// 编辑时记住原记录,保存时回填 isDefault 等未在表单暴露的字段
const [editingModel, setEditingModel] = useState<ModelResource | null>(null);
const [form, setForm] = useState<ModelForm>(emptyForm);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
const [testMessage, setTestMessage] = useState("");
const loadModels = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
setModels(await credentialsApi.list());
} catch (error) {
setLoadError(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// 挂载时拉取列表:这是与后端(外部系统)的同步,loadModels 内的 setLoading 属预期
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadModels();
}, [loadModels]);
function updateForm<K extends keyof ModelForm>(key: K, value: ModelForm[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
setTestResult("idle");
setTestMessage("");
}
async function handleTestConnection() {
setTesting(true);
setTestResult("idle");
setTestMessage("");
try {
const result = await credentialsApi.test(
{
modelId: form.modelId.trim(),
type: form.type,
interfaceType: form.interfaceType,
apiUrl: form.apiUrl.trim(),
apiKey: form.apiKey,
voice: form.voice.trim(),
speed: form.speed,
language: form.language.trim(),
},
editingId ?? undefined,
);
setTestResult(result.ok ? "ok" : "fail");
setTestMessage(
[
result.message,
result.latencyMs !== null ? `${result.latencyMs}ms` : "",
result.detail,
]
.filter(Boolean)
.join(" · "),
);
} catch (error) {
setTestResult("fail");
setTestMessage(error instanceof Error ? error.message : "测试连接失败");
} finally {
setTesting(false);
}
}
function handleTypeChange(type: ModelType) {
const options = interfaceOptionsByType[type];
setForm((prev) => ({
...prev,
type,
// 资源类型变化时,若当前接口类型不在新类型的可选项内则重置为首项
interfaceType: options.includes(prev.interfaceType)
? prev.interfaceType
: options[0],
voice: type === "TTS" ? prev.voice : "",
speed: type === "TTS" ? prev.speed : 1,
language: type === "ASR" ? prev.language : "",
}));
setTestResult("idle");
setTestMessage("");
}
function openCreate() {
setEditingId(null);
setEditingModel(null);
setForm(emptyForm);
setShowKey(false);
setTestResult("idle");
setTestMessage("");
setSaveError(null);
setDialogOpen(true);
}
function openEdit(model: ModelResource) {
setEditingId(model.id);
setEditingModel(model);
setForm({
name: model.name,
modelId: model.modelId,
type: model.type,
interfaceType: model.interfaceType,
apiUrl: model.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
voice: model.voice,
speed: model.speed,
language: model.language,
});
setShowKey(false);
setTestResult("idle");
setTestMessage("");
setSaveError(null);
setDialogOpen(true);
}
async function handleSave() {
setSaving(true);
setSaveError(null);
const payload = {
name: form.name.trim(),
modelId: form.modelId.trim(),
type: form.type,
interfaceType: form.interfaceType,
apiUrl: form.apiUrl.trim(),
apiKey: form.apiKey,
voice: form.voice.trim(),
speed: form.speed,
language: form.language.trim(),
// 表单未暴露 isDefault,编辑时沿用原值,新建默认 false
isDefault: editingModel?.isDefault ?? false,
};
try {
if (editingId) {
await credentialsApi.update(editingId, payload);
} else {
await credentialsApi.create(payload);
}
setDialogOpen(false);
await loadModels();
} catch (error) {
setSaveError(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
setDeletingId(id);
try {
await credentialsApi.remove(id);
await loadModels();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
// 服务端整行复制(含真 key,密钥不经浏览器)
async function handleDuplicate(id: string) {
setDuplicatingId(id);
try {
await credentialsApi.duplicate(id);
await loadModels();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "复制失败");
} finally {
setDuplicatingId(null);
}
}
const filteredModels = models.filter((model) => {
if (typeFilter !== "全部" && model.type !== typeFilter) {
return false;
}
const keyword = searchQuery.trim().toLowerCase();
if (!keyword) {
return true;
}
return [model.name, model.modelId, 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 hasStoredApiKey = Boolean(editingId && editingModel?.apiKey);
const hasRequiredTypeOptions =
form.type !== "TTS" || Boolean(form.voice.trim() && form.speed > 0);
const canSave =
form.name.trim() &&
form.modelId.trim() &&
form.apiUrl.trim() &&
hasRequiredTypeOptions;
const canTest = Boolean(
form.modelId.trim() &&
form.apiUrl.trim() &&
hasRequiredTypeOptions &&
(form.apiKey.trim() || editingModel?.apiKey),
);
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.modelId}
</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
className="rounded-lg"
disabled={duplicatingId === model.id}
onSelect={(event) => {
event.preventDefault();
void handleDuplicate(model.id);
}}
>
{duplicatingId === model.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Copy size={14} />
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === model.id}
onSelect={(event) => {
event.preventDefault();
void handleDelete(model.id);
}}
>
{deletingId === model.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{loading && (
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && loadError && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-destructive">
</div>
<div className="mt-2 text-sm text-muted-foreground">
{loadError}
</div>
<Button
variant="outline"
size="sm"
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => void loadModels()}
>
</Button>
</div>
)}
{!loading && !loadError && filteredModels.length === 0 && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
{models.length === 0 ? "暂无模型资源" : "未找到匹配的模型"}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{models.length === 0
? "点击右上角「添加模型」创建第一个资源。"
: "请调整关键词或筛选条件后再试。"}
</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="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
<DialogDescription>
ID访
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="block">
<FieldLabel
htmlFor="model-name"
hint={{
description:
"在控制台中展示的资源别名,便于识别和管理,可与模型 ID 不同。",
example: "DeepSeek-V3、Qwen-Max",
}}
>
</FieldLabel>
<Input
id="model-name"
value={form.name}
autoFocus
onChange={(event) => updateForm("name", event.target.value)}
placeholder="请输入资源名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
<div className="block">
<FieldLabel
htmlFor="model-id"
hint={{
description:
"调用 API 时传入的模型标识,需与服务商文档中的名称完全一致。",
example: "deepseek-chat、qwen-max、paraformer-realtime-v2",
}}
>
ID
</FieldLabel>
<Input
id="model-id"
value={form.modelId}
onChange={(event) =>
updateForm("modelId", event.target.value)
}
placeholder="例如 deepseek-chat"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="block">
<FieldLabel
hint={{
description:
"模型在语音管线中的能力分类,决定可选的接口类型与后续编排用途。",
example: "LLM、ASR、TTS、Realtime、Embedding",
}}
>
</FieldLabel>
<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">
<FieldLabel
hint={{
description:
"服务商 API 的协议或适配器类型,需与所选资源类型匹配。",
example: "openai、xfyun、dashscope、gemini",
}}
>
</FieldLabel>
<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>
<div className="block">
<FieldLabel
htmlFor="model-api-url"
hint={{
description:
"模型服务的 Base URL 或接口根地址,通常以 /v1 结尾,不含具体路径参数。",
example: "https://api.deepseek.com/v1",
}}
>
API URL
</FieldLabel>
<Input
id="model-api-url"
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"
/>
</div>
{form.type === "TTS" && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="block">
<FieldLabel
htmlFor="model-voice"
hint={{
description:
"调用语音合成接口时使用的音色标识,需与服务商支持的 voice 一致。",
example: "alloy、中文女",
}}
>
Voice
</FieldLabel>
<Input
id="model-voice"
value={form.voice}
onChange={(event) => updateForm("voice", event.target.value)}
placeholder="例如 alloy"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
<div className="block">
<FieldLabel
htmlFor="model-speed"
hint={{
description: "语音合成的播放速度1 表示正常速度。",
example: "0.8、1、1.2",
}}
>
Speed
</FieldLabel>
<Input
id="model-speed"
type="number"
min="0.25"
max="4"
step="0.1"
value={form.speed}
onChange={(event) =>
updateForm("speed", Number(event.target.value) || 1)
}
className="border-hairline-strong bg-background text-foreground"
/>
</div>
</div>
)}
{form.type === "ASR" && (
<div className="block">
<FieldLabel
htmlFor="model-language"
hint={{
description:
"语音识别的语言代码,留空时由模型自动判断或使用服务商默认值。",
example: "zh、en",
}}
>
Language
</FieldLabel>
<Input
id="model-language"
value={form.language}
onChange={(event) => updateForm("language", event.target.value)}
placeholder="例如 zh留空则自动识别"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
)}
<div className="block">
<FieldLabel
htmlFor="model-api-key"
hint={{
description:
"访问模型服务的鉴权密钥,由服务商控制台生成,请妥善保管勿泄露。",
example: "sk-xxxxxxxx",
}}
>
API Key
</FieldLabel>
{hasStoredApiKey && (
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<code className="rounded-md bg-surface-strong px-2 py-1 font-mono text-foreground">
{editingModel?.apiKey}
</code>
</div>
)}
<div className="relative">
<Input
id="model-api-key"
value={form.apiKey}
type={showKey ? "text" : "password"}
onChange={(event) => updateForm("apiKey", event.target.value)}
placeholder={
hasStoredApiKey
? "已配置,留空则保持不变"
: "请输入 API Key"
}
autoComplete="new-password"
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
/>
<button
type="button"
onClick={() => setShowKey((value) => !value)}
disabled={!form.apiKey}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{hasStoredApiKey && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
</p>
)}
</div>
</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={!canTest || 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} />
{testMessage}
</span>
)}
{testResult === "fail" && (
<span className="flex items-center gap-1.5 text-xs text-destructive">
<XCircle size={14} />
{testMessage}
</span>
)}
{saveError && (
<span className="flex items-center gap-1.5 text-xs text-destructive">
<XCircle size={14} />
{saveError}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={saving}
onClick={() => setDialogOpen(false)}
>
</Button>
<Button
className="gap-2"
disabled={!canSave || saving}
onClick={() => void handleSave()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Brain size={16} />
)}
{editingId ? "保存" : "添加"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function FieldLabel({
htmlFor,
children,
hint,
}: {
htmlFor?: string;
children: ReactNode;
hint: { description: string; example?: string };
}) {
return (
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-foreground">
<label htmlFor={htmlFor}>{children}</label>
<HelpHint {...hint} />
</div>
);
}
function HelpHint({
description,
example,
}: {
description: string;
example?: string;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看说明"
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={14} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 space-y-2 text-sm leading-6 text-muted-foreground"
>
<p>{description}</p>
{example && (
<p className="text-xs leading-5 text-muted-soft">
<span className="font-medium text-muted-foreground"></span>
{example}
</p>
)}
</PopoverContent>
</Popover>
);
}