refactor(admin): 模型配置 - 能力与请求协议改为品牌卡片单选

This commit is contained in:
ddCat
2026-08-04 22:13:47 +08:00
parent 8e0deaff48
commit c8defa8084
2 changed files with 144 additions and 19 deletions

View File

@@ -248,7 +248,8 @@ function modelMenuMeta(model: string, capability?: ModelCapability): { descripti
export function ModelIcon({ model }: { model: string }) {
const icon = resolveModelIcon(modelOptionName(model));
return icon ? <img src={icon} alt="" className="size-3.5 shrink-0 dark:invert" /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
const monochrome = icon === "/icons/openai.svg" || icon === "/icons/grok.svg";
return icon ? <img src={icon} alt="" className={cn("size-3.5 shrink-0", monochrome && "dark:invert")} /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
}
export function resolveModelIcon(model: string) {

View File

@@ -1,18 +1,22 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { AudioLines, Check, Film, Flame, FlaskConical, Image, MessageSquareText, Network, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { SYSTEM_MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSummary, type ModelProtocol } from "@/lib/model-protocols";
import { ModelIcon } from "@/components/model-picker";
import { cn } from "@/lib/utils";
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { AdminPageFrame } from "./admin-shell";
type EditableCapability = Exclude<ChannelModel["capability"], "">;
type FormValues = {
modelKey: string;
displayName?: string;
capability: ChannelModel["capability"];
capability: EditableCapability;
protocol: ModelProtocol;
billingMode: ChannelModel["billingMode"];
unitPrice: number;
@@ -36,7 +40,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const [form] = Form.useForm<FormValues>();
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
const modelCapability = Form.useWatch("capability", form);
const modelProtocol = Form.useWatch("protocol", form);
const modelKey = Form.useWatch("modelKey", form) || "";
const reload = async () => {
if (!channel) return;
@@ -79,7 +83,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const startCreate = () => {
setEditing(null);
form.setFieldsValue({ modelKey: "", displayName: "", capability: undefined, protocol: undefined, billingMode: "fixed_request", unitPrice: 0, enabled: true });
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, enabled: true });
setEditorOpen(true);
};
@@ -145,13 +149,25 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
};
const handleFormValuesChange = (changed: Partial<FormValues>) => {
if (!changed.capability) return;
if (changed.capability !== "video") form.setFieldValue("billingMode", "fixed_request");
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
if (modelProtocolCapability(current) !== changed.capability) {
form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value);
}
};
const columns: ColumnsType<ChannelModel> = [
{
title: "模型",
render: (_, item) => (
<div>
<div className="font-medium">{item.displayName || item.modelKey}</div>
<div className="text-xs text-foreground/45">{item.modelKey}</div>
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35"><ModelIcon model={item.modelKey} /></span>
<div className="min-w-0">
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
<div className="truncate text-xs text-foreground/45">{item.modelKey}</div>
</div>
</div>
),
},
@@ -208,23 +224,19 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
/>
</TableSurface>
<Drawer title={editing ? "编辑模型" : "新增模型"} open={editorOpen} size="min(720px, 100vw)" onClose={() => setEditorOpen(false)} styles={{ body: { paddingBottom: 88 } }} extra={editing ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={startCreate}>新增</Button> : null}>
<Form form={form} layout="vertical" requiredMark={false}>
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
<Form.Item name="modelKey" label="模型标识" rules={[{ required: true, message: "请输入模型标识" }]}>
<Input placeholder="gpt-image-2" />
<Input prefix={<span className="grid size-6 place-items-center"><ModelIcon model={modelKey} /></span>} placeholder="例如:deepseek-chat、gpt-5、glm-4.5" />
</Form.Item>
<Form.Item name="displayName" label="显示名称">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="capability" label="能力" rules={[{ required: true }]}>
<Select onChange={(value) => { if (value !== "video") form.setFieldValue("billingMode", "fixed_request"); const current = form.getFieldValue("protocol") as ModelProtocol; if (modelProtocolCapability(current) !== value) form.setFieldValue("protocol", SYSTEM_MODEL_PROTOCOL_OPTIONS.flatMap((group) => group.options).find((item) => modelProtocolCapability(item.value) === value)?.value); }} options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
<CapabilityCardPicker />
</Form.Item>
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]} extra="协议决定创建路径、Content-Type、请求字段和轮询方式。">
<Select options={SYSTEM_MODEL_PROTOCOL_OPTIONS.map((group) => ({ ...group, options: group.options.filter((item) => modelProtocolCapability(item.value) === modelCapability) }))} />
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
<ProtocolCardPicker capability={modelCapability} />
</Form.Item>
<div className="mb-5 rounded-md border border-border/70 bg-foreground/[.025] px-3 py-2.5">
<div className="text-xs font-semibold">实际请求预览</div>
<div className="mt-1 font-mono text-[var(--fs-label)] leading-5 text-foreground/60">{modelProtocolSummary(modelProtocol)}</div>
</div>
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
</Form.Item>
@@ -247,6 +259,118 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
);
}
const capabilityChoices: Array<{
value: EditableCapability;
label: string;
description: string;
icon: ReactNode;
brands: string[];
}> = [
{ value: "text", label: "文本", description: "对话与推理", icon: <MessageSquareText className="size-4" />, brands: ["openai", "deepseek", "glm"] },
{ value: "image", label: "图片", description: "生成与编辑", icon: <Image className="size-4" />, brands: ["openai", "gemini"] },
{ value: "video", label: "视频", description: "生成与续写", icon: <Film className="size-4" />, brands: ["grok", "gemini"] },
{ value: "audio", label: "音频", description: "语音与音效", icon: <AudioLines className="size-4" />, brands: ["openai"] },
];
function CapabilityCardPicker({ value, onChange }: { value?: EditableCapability; onChange?: (value: EditableCapability) => void }) {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="模型能力">
{capabilityChoices.map((item) => {
const selected = value === item.value;
return (
<button
key={item.value}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
)}
onClick={() => onChange?.(item.value)}
>
<span className={cn("grid size-8 place-items-center rounded-md", selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/65")}>{item.icon}</span>
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
<span className="mt-2 block text-sm font-semibold">{item.label}</span>
<span className="block text-xs text-foreground/48">{item.description}</span>
<BrandIconRow models={item.brands} className="mt-auto pt-2" />
</button>
);
})}
</div>
);
}
function ProtocolCardPicker({ capability, value, onChange }: { capability?: EditableCapability; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void }) {
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" role="radiogroup" aria-label="模型请求协议">
{protocols.map((protocol) => {
const selected = value === protocol.value;
return (
<button
key={protocol.value}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
)}
onClick={() => onChange?.(protocol.value)}
>
<div className="flex min-w-0 items-start gap-2.5 pr-6">
<ProtocolBrandMark protocol={protocol} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{protocol.label}</div>
<div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/48">{protocol.create}</div>
</div>
</div>
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-foreground/58">{protocol.media}</div>
<div className="mt-auto flex items-center justify-between gap-2 pt-2 text-[var(--fs-tiny)] text-foreground/42">
<span className="truncate">{protocol.contentType}</span>
{protocol.poll ? <span className="shrink-0">异步轮询</span> : <span className="shrink-0">同步响应</span>}
</div>
</button>
);
})}
</div>
);
}
function ProtocolBrandMark({ protocol }: { protocol: ModelProtocolDefinition }) {
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact />;
if (protocol.value.startsWith("volcengine-jimeng-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Sparkles className="size-4" /></span>;
if (protocol.value.startsWith("volcengine-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Flame className="size-4" /></span>;
if (protocol.value.startsWith("newapi-channel-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Network className="size-4" /></span>;
const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai";
return <BrandIconRow models={[brand]} compact />;
}
function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) {
return (
<span className={cn("flex items-center", compact ? "shrink-0 -space-x-1" : "-space-x-1", className)} aria-hidden="true">
{models.map((model) => (
<span key={model} className={cn("grid shrink-0 place-items-center rounded-md border border-border/70 bg-background", compact ? "size-8" : "size-6")} title={modelBrandLabel(model)}>
<ModelIcon model={model} />
</span>
))}
</span>
);
}
function modelBrandLabel(model: string) {
const labels: Record<string, string> = {
openai: "OpenAI",
deepseek: "DeepSeek",
glm: "智谱 GLM",
gemini: "Google Gemini",
grok: "xAI Grok",
};
return labels[model] || model;
}
function capabilityLabel(value: ChannelModel["capability"]) {
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
}