Files
ai-video-fullstack/frontend/src/components/pages/ComponentsModelsPage.tsx
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
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 {
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>
);
}