Files
ai-video-fullstack/frontend/src/components/pages/ComponentsModelsPage.tsx
T
Xin Wang 54329aa516 Add Agent capabilities and configurations across the application
- Extend the AssistantConfig model to include agent_interface_type, agent_values, and agent_secrets for enhanced agent management.
- Update ModelType to include "Agent" for broader capability support.
- Seed new agent models and configurations in the database for Dify, FastGPT, and OpenCode applications.
- Modify the Assistant routes to validate and handle agent capabilities.
- Implement agent resource resolution in the config resolver for runtime configuration.
- Enhance the interface catalog with agent-specific fields and definitions.
- Update frontend components to manage agent configurations, including form handling and state management for agent-related inputs.
2026-07-09 15:29:25 +08:00

1043 lines
33 KiB
TypeScript

"use client";
import {
CheckCircle2,
ChevronDown,
ChevronUp,
Copy,
Eye,
EyeOff,
Loader2,
MoreHorizontal,
Pencil,
Plug,
Plus,
Trash2,
XCircle,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DataList } from "@/components/ui/data-list";
import { PageHeader } from "@/components/ui/page-header";
import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
interfaceDefinitionsApi,
modelResourcesApi,
type InterfaceDefinition,
type InterfaceField,
type ModelResource,
type ModelType,
} from "@/lib/api";
const capabilities: ModelType[] = [
"LLM",
"ASR",
"TTS",
"Realtime",
"Embedding",
"Agent",
];
const capabilityFilters = ["全部", ...capabilities] as const;
type ResourceDraft = {
name: string;
capability: ModelType;
interfaceType: string;
values: Record<string, unknown>;
secrets: Record<string, unknown>;
supportImageInput: boolean;
};
const emptyDraft: ResourceDraft = {
name: "",
capability: "LLM",
interfaceType: "",
values: {},
secrets: {},
supportImageInput: false,
};
function defaults(definition: InterfaceDefinition): Record<string, unknown> {
return Object.fromEntries(
definition.fieldSchema.fields
.filter((field) => field.group === "values" && field.default !== undefined)
.map((field) => [field.key, field.default]),
);
}
function formatUpdatedAt(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
// 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前
type SortOrder = "newest" | "oldest";
// 缺失/非法时间戳视为最早(0),用于排序
function updatedAtValue(value?: string | null): number {
const time = value ? new Date(value).getTime() : NaN;
return Number.isNaN(time) ? 0 : time;
}
function fieldValue(draft: ResourceDraft, field: InterfaceField): unknown {
return field.group === "secrets"
? draft.secrets[field.key]
: draft.values[field.key];
}
function secretMask(
masks: Record<string, unknown>,
field: InterfaceField,
): string {
if (field.group !== "secrets") return "";
const value = masks[field.key];
return typeof value === "string" && value.includes("****") ? value : "";
}
function extraBodyText(values: Record<string, unknown>): string {
const value = values.extraBody;
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
return JSON.stringify(value, null, 2);
}
function parseExtraBody(text: string):
| { ok: true; value: Record<string, unknown> | null }
| { ok: false; error: string } {
const trimmed = text.trim();
if (!trimmed) return { ok: true, value: null };
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: "Extra Body 必须是 JSON 对象" };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false, error: "Extra Body 不是合法 JSON" };
}
}
function valuesWithExtraBody(
values: Record<string, unknown>,
extraBody: Record<string, unknown> | null,
): Record<string, unknown> {
const next = { ...values };
delete next.extraBody;
if (extraBody) next.extraBody = extraBody;
return next;
}
function isSelectableDefinition(definition: InterfaceDefinition): boolean {
return (
definition.capability !== "LLM" || definition.interfaceType === "openai-llm"
);
}
export function ComponentsModelsPage() {
const [resources, setResources] = useState<ModelResource[]>([]);
const [definitions, setDefinitions] = useState<InterfaceDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ModelType | "全部">("全部");
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
const [currentPage, setCurrentPage] = useState(1);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<ResourceDraft>(emptyDraft);
const [storedSecretMasks, setStoredSecretMasks] = useState<Record<string, unknown>>({});
const [extraBodyDraft, setExtraBodyDraft] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
ok: boolean;
message: string;
} | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setLoadError("");
try {
const [nextResources, nextDefinitions] = await Promise.all([
modelResourcesApi.list(),
interfaceDefinitionsApi.list(),
]);
setResources(nextResources);
setDefinitions(nextDefinitions);
} catch (error) {
setLoadError(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// Initial external API synchronization.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const selectableDefinitions = definitions.filter(isSelectableDefinition);
const availableDefinitions = selectableDefinitions.filter(
(definition) => definition.capability === draft.capability,
);
const selectedDefinition = definitions.find(
(definition) => definition.interfaceType === draft.interfaceType,
);
const connectionFields =
selectedDefinition?.fieldSchema.fields.filter(
(field) => field.group === "secrets" || field.required,
) ?? [];
const optionalFields =
selectedDefinition?.fieldSchema.fields.filter(
(field) => field.group === "values" && !field.required,
) ?? [];
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
return resources.filter((resource) => {
if (filter !== "全部" && resource.capability !== filter) return false;
return (
!keyword ||
[
resource.name,
resource.interfaceType,
String(resource.values.modelId ?? ""),
]
.join(" ")
.toLowerCase()
.includes(keyword)
);
});
}, [resources, search, filter]);
// 按更新时间排序(以原始时间戳为准);id 作为稳定的次级排序键
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt);
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
return a.id.localeCompare(b.id);
});
}, [filtered, sortOrder]);
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedResources = sorted.slice(pageStart, pageEnd);
function changeFilter(nextFilter: ModelType | "全部") {
setFilter(nextFilter);
setCurrentPage(1);
}
function changeSearch(value: string) {
setSearch(value);
setCurrentPage(1);
}
function changeSort(order: SortOrder) {
setSortOrder(order);
setCurrentPage(1);
}
function toggleSortOrder() {
changeSort(sortOrder === "newest" ? "oldest" : "newest");
}
function selectDefinition(interfaceType: string) {
const definition = definitions.find(
(item) => item.interfaceType === interfaceType,
);
setDraft((previous) => ({
...previous,
interfaceType,
values: definition ? defaults(definition) : {},
secrets: {},
}));
setStoredSecretMasks({});
setExtraBodyDraft("");
setTestResult(null);
}
function selectCapability(capability: ModelType) {
const first = selectableDefinitions.find(
(definition) => definition.capability === capability,
);
setDraft((previous) => ({
...previous,
capability,
interfaceType: first?.interfaceType ?? "",
values: first ? defaults(first) : {},
secrets: {},
supportImageInput:
capability === "LLM" ? previous.supportImageInput : false,
}));
setStoredSecretMasks({});
setExtraBodyDraft("");
setTestResult(null);
}
function openCreate() {
const first = selectableDefinitions.find(
(definition) => definition.capability === "LLM",
);
setEditingId(null);
setDraft({
...emptyDraft,
interfaceType: first?.interfaceType ?? "",
values: first ? defaults(first) : {},
secrets: {},
supportImageInput: false,
});
setStoredSecretMasks({});
setExtraBodyDraft("");
setSaveError("");
setTestResult(null);
setDialogOpen(true);
}
function openEdit(resource: ModelResource) {
setEditingId(resource.id);
setDraft({
name: resource.name,
capability: resource.capability,
interfaceType: resource.interfaceType,
values: resource.values,
secrets: {},
supportImageInput: resource.supportImageInput,
});
setStoredSecretMasks(resource.secrets);
setExtraBodyDraft(extraBodyText(resource.values));
setSaveError("");
setTestResult(null);
setDialogOpen(true);
}
function updateField(field: InterfaceField, value: unknown) {
const target = field.group === "secrets" ? "secrets" : "values";
setDraft((previous) => ({
...previous,
[target]: { ...previous[target], [field.key]: value },
}));
setTestResult(null);
}
const parsedExtraBody = parseExtraBody(extraBodyDraft);
const extraBodyError = parsedExtraBody.ok ? "" : parsedExtraBody.error;
const canSave = Boolean(
draft.name.trim() &&
selectedDefinition &&
!extraBodyError &&
selectedDefinition.fieldSchema.fields.every((field) => {
if (!field.required) return true;
const value =
field.group === "secrets" && secretMask(storedSecretMasks, field)
? secretMask(storedSecretMasks, field)
: fieldValue(draft, field);
return value !== undefined && value !== null && value !== "";
}),
);
function payload() {
const extraBody = parsedExtraBody.ok ? parsedExtraBody.value : null;
return {
name: draft.name.trim(),
interfaceType: draft.interfaceType,
values:
draft.capability === "LLM"
? valuesWithExtraBody(draft.values, extraBody)
: draft.values,
secrets: editingId
? { ...storedSecretMasks, ...draft.secrets }
: draft.secrets,
supportImageInput:
draft.capability === "LLM" ? draft.supportImageInput : false,
enabled: editingId
? resources.find((resource) => resource.id === editingId)?.enabled ?? true
: true,
isDefault: editingId
? resources.find((resource) => resource.id === editingId)?.isDefault ?? false
: false,
};
}
async function testConnection() {
if (!canSave) return;
setTesting(true);
setTestResult(null);
try {
const result = await modelResourcesApi.test(payload(), editingId ?? undefined);
setTestResult({
ok: result.ok,
message: [
result.message,
result.latencyMs !== null ? `${result.latencyMs}ms` : "",
result.detail,
]
.filter(Boolean)
.join(" · "),
});
} catch (error) {
setTestResult({
ok: false,
message: error instanceof Error ? error.message : "测试连接失败",
});
} finally {
setTesting(false);
}
}
async function save() {
if (!canSave) return;
setSaving(true);
setSaveError("");
const body = payload();
try {
if (editingId) {
await modelResourcesApi.update(editingId, body);
} else {
await modelResourcesApi.create(body);
}
setDialogOpen(false);
await load();
} catch (error) {
setSaveError(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function remove(id: string) {
setDeletingId(id);
try {
await modelResourcesApi.remove(id);
await load();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
async function duplicate(id: string) {
setDuplicatingId(id);
try {
await modelResourcesApi.duplicate(id);
await load();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "复制失败");
} finally {
setDuplicatingId(null);
}
}
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
title="模型资源"
description="接口定义决定能力、鉴权字段和调用参数,表单会随接口类型动态更新。"
action={
<Button
size="lg"
className="w-full gap-2 sm:w-auto"
onClick={openCreate}
>
<Plus size={16} />
添加模型
</Button>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListToolbar
filters={
<FilterPills
options={capabilityFilters}
value={filter}
onChange={changeFilter}
/>
}
search={
<SearchInput
value={search}
onChange={changeSearch}
placeholder="搜索名称、接口类型或模型 ID"
className="lg:w-[320px]"
/>
}
/>
<DataList<ModelResource>
rows={paginatedResources}
rowKey={(resource) => resource.id}
loading={loading}
error={loadError || null}
onRetry={() => void load()}
empty={{
title:
resources.length === 0
? "暂无模型资源"
: "未找到匹配的模型资源",
description:
resources.length === 0
? "点击右上角「添加模型」开始。"
: "请调整关键词或筛选条件后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
filtered.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个模型资源`,
}}
columns={[
{
key: "name",
header: "模型名称",
width: "md:w-[360px]",
cell: (resource) => (
<>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">
{resource.name}
</span>
{resource.supportImageInput && (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-surface-strong text-muted-foreground"
title="支持图片输入"
aria-label="支持图片输入"
>
<Eye size={12} />
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-muted-soft">
{String(
resource.values.modelId ?? resource.values.apiUrl ?? "",
)}
</div>
</>
),
},
{
key: "capability",
header: "能力",
width: "md:w-[128px]",
cell: (resource) => (
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{resource.capability}
</Badge>
),
},
{
key: "interfaceType",
header: "接口类型",
width: "md:w-[220px]",
cell: (resource) => (
<Badge variant="outline" className="h-6 px-3">
{resource.interfaceType}
</Badge>
),
},
{
key: "updatedAt",
width: "md:w-[176px]",
header: (
<button
type="button"
onClick={toggleSortOrder}
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={
sortOrder === "newest"
? "当前按最近更新排序,点击切换为最早更新"
: "当前按最早更新排序,点击切换为最近更新"
}
>
更新时间
{sortOrder === "newest" ? (
<ChevronDown size={13} />
) : (
<ChevronUp size={13} />
)}
</button>
),
cellClassName:
"whitespace-nowrap tabular-nums text-muted-foreground",
cell: (resource) => formatUpdatedAt(resource.updatedAt),
},
{
key: "actions",
header: "操作",
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (resource) => (
<>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => openEdit(resource)}
>
<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={`${resource.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 === resource.id}
onSelect={(event) => {
event.preventDefault();
void duplicate(resource.id);
}}
>
{duplicatingId === resource.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Copy size={14} />
)}
复制
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === resource.id}
onSelect={(event) => {
event.preventDefault();
void remove(resource.id);
}}
>
{deletingId === resource.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
),
},
]}
/>
</section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
<DialogDescription>
选择具体接口类型后,系统会从接口定义生成需要填写的字段。修改能力类型会自动取消不再兼容的助手绑定。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-5 lg:flex lg:h-[38rem] lg:flex-col lg:space-y-0 lg:gap-5">
<FieldSection title="基本信息">
<Field label="资源名称" required>
<Input
value={draft.name}
onChange={(event) =>
setDraft((previous) => ({
...previous,
name: event.target.value,
}))
}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="能力类型" required>
<Select
value={draft.capability}
onValueChange={(value) =>
selectCapability(value as ModelType)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{capabilities.map((capability) => (
<SelectItem key={capability} value={capability}>
{capability}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="接口类型" required>
<Select
value={draft.interfaceType}
onValueChange={selectDefinition}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择接口类型" />
</SelectTrigger>
<SelectContent>
{availableDefinitions.map((definition) => (
<SelectItem
key={definition.interfaceType}
value={definition.interfaceType}
>
{definition.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{draft.capability === "LLM" && (
<div className="flex items-center justify-between rounded-xl border border-hairline p-4">
<span className="text-sm font-medium">支持图片输入</span>
<Switch
checked={draft.supportImageInput}
onCheckedChange={(checked) =>
setDraft((previous) => ({
...previous,
supportImageInput: checked,
}))
}
/>
</div>
)}
</FieldSection>
<FieldSection title="连接与鉴权信息" scrollable fill>
{connectionFields.map((field) => (
<DynamicField
key={`${field.group}-${field.key}`}
field={field}
value={fieldValue(draft, field)}
storedValueMask={secretMask(storedSecretMasks, field)}
onChange={(value) => updateField(field, value)}
/>
))}
</FieldSection>
</div>
<FieldSection title="可选项" scrollable tall>
{draft.capability === "LLM" && (
<Field label="Extra Body">
<Textarea
rows={7}
value={extraBodyDraft}
onChange={(event) => {
setExtraBodyDraft(event.target.value);
setTestResult(null);
}}
placeholder={'例如:{\n "thinking": { "type": "disabled" }\n}'}
className="font-mono text-xs leading-5"
/>
{extraBodyError && (
<div className="text-xs text-destructive">
{extraBodyError}
</div>
)}
</Field>
)}
{optionalFields.length > 0 ? (
optionalFields.map((field) => (
<DynamicField
key={`${field.group}-${field.key}`}
field={field}
value={fieldValue(draft, field)}
storedValueMask={secretMask(storedSecretMasks, field)}
onChange={(value) => updateField(field, value)}
/>
))
) : draft.capability === "LLM" ? null : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
当前接口没有可选项
</div>
)}
</FieldSection>
</div>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
<DialogFooter className="sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="outline"
className="shrink-0 gap-2"
disabled={!canSave || testing}
onClick={() => void testConnection()}
>
{testing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plug size={14} />
)}
测试连接
</Button>
{testResult && (
<span
className={[
"flex min-w-0 items-center gap-1.5 text-xs",
testResult.ok ? "text-emerald-500" : "text-destructive",
].join(" ")}
title={testResult.message}
>
{testResult.ok ? (
<CheckCircle2 size={14} className="shrink-0" />
) : (
<XCircle size={14} className="shrink-0" />
)}
<span className="truncate">{testResult.message}</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button disabled={!canSave || saving} onClick={() => void save()}>
{saving && <Loader2 size={14} className="animate-spin" />}
保存
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{children}
</label>
);
}
function FieldSection({
title,
scrollable,
tall,
fill,
children,
}: {
title: string;
scrollable?: boolean;
tall?: boolean;
fill?: boolean;
children: React.ReactNode;
}) {
return (
<section
className={[
"rounded-xl border border-hairline bg-surface-strong/20",
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
fill ? "lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "",
].join(" ")}
>
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{title}
</div>
<div
className={[
"space-y-4 p-4",
scrollable ? "max-h-72 overflow-y-auto" : "",
tall ? "lg:max-h-none lg:flex-1" : "",
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
].join(" ")}
>
{children}
</div>
</section>
);
}
function DynamicField({
field,
value,
storedValueMask,
onChange,
}: {
field: InterfaceField;
value: unknown;
storedValueMask?: string;
onChange: (value: unknown) => void;
}) {
const [showPassword, setShowPassword] = useState(false);
if (field.type === "boolean") {
return (
<div className="flex items-center justify-between rounded-xl border border-hairline p-4">
<span className="text-sm font-medium">{field.label}</span>
<Button
type="button"
size="sm"
variant={Boolean(value) ? "default" : "outline"}
onClick={() => onChange(!Boolean(value))}
>
{Boolean(value) ? "开启" : "关闭"}
</Button>
</div>
);
}
if (field.type === "select") {
return (
<Field label={field.label} required={field.required}>
<Select value={String(value ?? "")} onValueChange={onChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (field.type === "password") {
const inputValue = String(value ?? "");
const hasStoredValue = Boolean(storedValueMask);
return (
<Field label={field.label} required={field.required}>
{hasStoredValue && (
<div className="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">
{storedValueMask}
</code>
</div>
)}
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={inputValue}
onChange={(event) => {
const nextValue = event.target.value;
if (!nextValue) setShowPassword(false);
onChange(nextValue);
}}
placeholder={
hasStoredValue ? "已配置,留空则保持不变" : undefined
}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
disabled={!inputValue}
onClick={() => setShowPassword((current) => !current)}
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={showPassword ? "隐藏密钥" : "显示密钥"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{hasStoredValue && (
<p className="text-xs leading-5 text-muted-foreground">
仅显示当前密钥首尾用于识别。留空可保持原密钥,输入新值将覆盖原密钥。
</p>
)}
</Field>
);
}
return (
<Field label={field.label} required={field.required}>
<Input
type={
field.type === "number"
? "number"
: field.type === "url"
? "url"
: "text"
}
value={String(value ?? "")}
onChange={(event) =>
onChange(
field.type === "number"
? Number(event.target.value)
: event.target.value,
)
}
/>
</Field>
);
}