feat(compute): 供应商可编辑+模型创建优化设计

- vendors: 新增编辑支持, 表单含「支持的模型」清单(逗号分隔, 创建模型时可选)
- models: 创建改「选供应商→从该供应商支持模型选实际使用模型」(datalist 建议+可自定义); 完整配置: 模型名称/模型标识/实际使用模型/所属分组/计费单位/输入/输出/缓存命中价格/视觉支持/标签/描述/状态
- 列表新增实际模型/分组/输入输出价/视觉列; 供应商下拉+模型建议经 computeProxy 拉取
This commit is contained in:
Pine
2026-08-25 12:32:12 +08:00
parent aaedbfe3d4
commit b8bb1ce38b
2 changed files with 90 additions and 24 deletions
+76 -24
View File
@@ -10,10 +10,24 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { computeAction, useComputeList, fmtInt, fmtTime } from "./useCompute";
import { computeProxy } from "@/api/resources";
import { computeAction, useComputeList, fmtTime } from "./useCompute";
interface Row { [k: string]: any }
const blank: Row = { model_name: "", description: "", icon: "", tags: "", endpoints: "", status: 1, sync_official: 1, vendor_id: 0 };
const blank: Row = {
model_name: "", name: "", actual_model: "", vendor_id: 0, group: "default",
billing_unit: "/百万tokens", cache_hit_price: 0, input_price: 0, output_price: 0,
vision_support: false, tags: "", endpoints: "", description: "", icon: "", status: 1, sync_official: 1,
};
function vendorModels(vendor: Row | undefined): string[] {
const m = vendor?.models;
if (Array.isArray(m)) return m.filter(Boolean);
if (typeof m === "string") {
try { const a = JSON.parse(m); return Array.isArray(a) ? a.filter(Boolean) : []; } catch { return []; }
}
return [];
}
export default function ModelsPage() {
const [status, setStatus] = React.useState("1");
@@ -26,25 +40,28 @@ export default function ModelsPage() {
async function toggle(r: Row) {
setBusy(true); setMsg(null);
const out = await computeAction("PUT", "models", { ...r, id: r.id, status: r.status === 1 ? 0 : 1 });
setMsg(out.ok ? `模型 ${r.model_name}${r.status === 1 ? "停用" : "启用"}` : `操作失败:${out.message}`);
setMsg(out.ok ? `模型 ${r.name || r.model_name}${r.status === 1 ? "停用" : "启用"}` : `操作失败:${out.message}`);
setBusy(false); res.reload();
}
async function del(r: Row) {
setBusy(true); setMsg(null);
const out = await computeAction("DELETE", `models/${r.id}`);
setMsg(out.ok ? `已删除 ${r.model_name}` : `删除失败:${out.message}`);
setMsg(out.ok ? `已删除 ${r.name || r.model_name}` : `删除失败:${out.message}`);
setBusy(false); res.reload();
}
const columns: Column<Row>[] = [
{ key: "id", header: "ID", cell: (r) => <span className="text-muted-foreground">{r.id}</span> },
{ key: "model_name", header: "模型", cell: (r) => <span className="font-medium">{r.model_name}</span> },
{ key: "tags", header: "标签", cell: (r) => r.tags || "-" },
{ key: "vendor_id", header: "供应商", cell: (r) => r.vendor_id ? `#${fmtInt(r.vendor_id)}` : "-" },
{ key: "endpoints", header: "接入端点", cell: (r) => <span className="text-xs text-muted-foreground">{r.endpoints || "-"}</span> },
{ key: "matched_count", header: "匹配通道", cell: (r) => fmtInt(r.matched_count) },
{ key: "model_name", header: "模型", cell: (r) => (
<div><div className="font-medium">{r.name || r.model_name}</div><div className="text-xs text-muted-foreground">{r.model_name}</div></div>
) },
{ key: "actual_model", header: "实际模型", cell: (r) => r.actual_model || r.model_name },
{ key: "group", header: "分组", cell: (r) => r.group || "default" },
{ key: "input_price", header: "输入(¥)", cell: (r) => r.input_price ?? "-" },
{ key: "output_price", header: "输出(¥)", cell: (r) => r.output_price ?? "-" },
{ key: "vision_support", header: "视觉", cell: (r) => (r.vision_support ? <Badge variant="default"></Badge> : <Badge variant="secondary"></Badge>) },
{ key: "status", header: "状态", cell: (r) => <Badge variant={r.status === 1 ? "default" : "secondary"}>{r.status === 1 ? "启用" : "停用"}</Badge> },
{ key: "updated", header: "更新时间", cell: (r) => fmtTime(r.updated_time) },
{ key: "updated", header: "更新", cell: (r) => fmtTime(r.updated_time) },
{
key: "actions", header: "操作", cell: (r) => (
<div className="flex gap-1">
@@ -60,7 +77,7 @@ export default function ModelsPage() {
return (
<div className="space-y-4">
<PageHeader title="模型管理" description="模型元数据(映射 / 标签 / 供应商 / 端点)——对齐 new-api models"
<PageHeader title="模型管理" description="模型元数据(名称/实际模型/供应商/分组/计费/视觉),可从供应商支持模型中选择"
actions={
<div className="flex items-center gap-2">
<span className={cn("text-sm", msg?.includes("失败") ? "text-destructive" : "text-muted-foreground")}>{msg}</span>
@@ -94,18 +111,34 @@ export default function ModelsPage() {
);
}
function ModelFormDialog({ open, row, onClose, onDone }: {
open: boolean; row: Row | null; onClose: () => void; onDone: () => void;
}) {
function ModelFormDialog({ open, row, onClose, onDone }: { open: boolean; row: Row | null; onClose: () => void; onDone: () => void }) {
const [f, setF] = React.useState<Row>(blank);
const [vendors, setVendors] = React.useState<Row[]>([]);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState<string | null>(null);
React.useEffect(() => { if (open) { setF(row?.id ? blank : { ...blank, ...row }); setErr(null); } }, [open, row]);
const vendor = vendors.find((v) => v.id === Number(f.vendor_id));
const avail = vendorModels(vendor);
const set = (k: string, v: any) => setF((p) => ({ ...p, [k]: v }));
React.useEffect(() => {
if (open) {
setF(row?.id ? { ...blank, ...row, model_name: row.model_name ?? "", name: row.name ?? "" } : blank);
setErr(null);
computeProxy<{ items: Row[] }>("GET", "vendors", undefined, { p: 1, page_size: 100 })
.then((r: any) => setVendors((r?.body?.data?.items) ?? []))
.catch(() => setVendors([]));
}
}, [open, row]);
async function save() {
setBusy(true); setErr(null);
const body = { ...f, id: row?.id, vendor_id: Number(f.vendor_id) || 0, status: Number(f.status ?? 1), sync_official: Number(f.sync_official ?? 1) };
const body: any = {
...f, id: row?.id, vendor_id: Number(f.vendor_id) || 0,
input_price: Number(f.input_price) || 0, output_price: Number(f.output_price) || 0,
cache_hit_price: Number(f.cache_hit_price) || 0, status: Number(f.status ?? 1),
vision_support: !!f.vision_support, actual_model: f.actual_model || f.model_name,
};
delete body.created_time; delete body.updated_time; delete body.matched_count;
const out = row?.id ? await computeAction("PUT", "models", body) : await computeAction("POST", "models", body);
setBusy(false);
if (out.ok) onDone(); else setErr(out.message || "保存失败");
@@ -113,20 +146,39 @@ function ModelFormDialog({ open, row, onClose, onDone }: {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader><DialogTitle>{row?.id ? "编辑模型" : "新增模型"}</DialogTitle></DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="模型名" full><Input value={f.model_name} onChange={(e) => set("model_name", e.target.value)} placeholder="gpt-4o" /></Field>
<Field label="标签"><Input value={f.tags || ""} onChange={(e) => set("tags", e.target.value)} placeholder="chat,vision" /></Field>
<Field label="供应商 ID"><Input type="number" value={f.vendor_id || 0} onChange={(e) => set("vendor_id", e.target.value)} /></Field>
<Field label="接入端点" full><Input value={f.endpoints || ""} onChange={(e) => set("endpoints", e.target.value)} placeholder="gpt-4o,chatgpt-4o" /></Field>
<Field label="所属供应商" full>
<Select value={String(f.vendor_id ?? 0)} onValueChange={(v) => set("vendor_id", Number(v))}>
<SelectTrigger><SelectValue placeholder="选择供应商" /></SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
{vendors.map((v) => <SelectItem key={v.id} value={String(v.id)}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="模型名称(展示)"><Input value={f.name || ""} onChange={(e) => set("name", e.target.value)} placeholder="DeepSeek Chat" /></Field>
<Field label="模型标识(请求用,如 deepseek-chat"><Input value={f.model_name} onChange={(e) => set("model_name", e.target.value)} placeholder="deepseek-chat" /></Field>
<Field label="实际使用的模型" full>
<Input list="model-suggest" value={f.actual_model || f.model_name} onChange={(e) => set("actual_model", e.target.value)} placeholder="选择或输入上游模型名" />
<datalist id="model-suggest">
{avail.map((m) => <option key={m} value={m} />)}
</datalist>
</Field>
<Field label="所属分组"><Input value={f.group || "default"} onChange={(e) => set("group", e.target.value)} /></Field>
<Field label="计费单位"><Input value={f.billing_unit || "/百万tokens"} onChange={(e) => set("billing_unit", e.target.value)} /></Field>
<Field label="输入价格(¥)"><Input type="number" value={f.input_price} onChange={(e) => set("input_price", e.target.value)} /></Field>
<Field label="输出价格(¥)"><Input type="number" value={f.output_price} onChange={(e) => set("output_price", e.target.value)} /></Field>
<Field label="缓存命中价格(¥)"><Input type="number" value={f.cache_hit_price} onChange={(e) => set("cache_hit_price", e.target.value)} /></Field>
<Field label="标签"><Input value={f.tags || ""} onChange={(e) => set("tags", e.target.value)} /></Field>
<Field label="描述" full><Input value={f.description || ""} onChange={(e) => set("description", e.target.value)} /></Field>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" checked={!!f.vision_support} onChange={(e) => set("vision_support", e.target.checked)} /> /
</label>
<Field label="状态"><Select value={String(f.status ?? 1)} onValueChange={(v) => set("status", Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger><SelectContent>
<SelectItem value="1"></SelectItem><SelectItem value="0"></SelectItem></SelectContent></Select></Field>
<Field label="同步官方"><Select value={String(f.sync_official ?? 1)} onValueChange={(v) => set("sync_official", Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger><SelectContent>
<SelectItem value="1"></SelectItem><SelectItem value="0"></SelectItem></SelectContent></Select></Field>
</div>
{err && <p className="text-sm text-destructive">{err}</p>}
<DialogFooter><Button variant="outline" onClick={onClose} disabled={busy}></Button>
+14
View File
@@ -97,11 +97,23 @@ function VendorFormDialog({ open, row, onClose, onDone }: { open: boolean; row:
setBusy(true); setErr(null);
const body = { ...f, id: row?.id, status: Number(f.status ?? 1) };
delete body.created_time; delete body.updated_time;
// models: 逗号/换行分隔 → JSON 数组
body.models = Array.isArray(f.models)
? f.models
: String(f.models || "").split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
const out = row?.id ? await computeAction("PUT", "vendors", body) : await computeAction("POST", "vendors", body);
setBusy(false);
if (out.ok) onDone(); else setErr(out.message || "保存失败");
}
function parseModels(v: any): string {
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "string") {
try { const a = JSON.parse(v); return Array.isArray(a) ? a.join(", ") : v; } catch { return v; }
}
return "";
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
@@ -109,6 +121,8 @@ function VendorFormDialog({ open, row, onClose, onDone }: { open: boolean; row:
<div className="grid gap-3">
<Label></Label><Input value={f.name} onChange={(e) => set("name", e.target.value)} />
<Label> URL</Label><Input value={f.icon || ""} onChange={(e) => set("icon", e.target.value)} />
<Label></Label>
<Input value={parseModels(f.models)} onChange={(e) => set("models", e.target.value)} placeholder="deepseek-chat, deepseek-reasoner" />
<Label></Label><Input value={f.description || ""} onChange={(e) => set("description", e.target.value)} />
<Label></Label>
<Select value={String(f.status ?? 1)} onValueChange={(v) => set("status", Number(v))}>